The short version
I found a security bug in a small Node.js game (Dojo #54 - "Highscore"). The app checks your "session" value and only allows letters in it. I found a trick to sneak in symbols the filter should have blocked — quotes, curly braces, commas — and used them to rewrite the database query the app was about to run. That let me log in as ANY user without ever having a real password or session, and grab the flag.
If you've never done a CTF or bug bounty before, don't worry — I'll explain everything with simple examples, no jargon dump.
Some background you need first
What is "the app" actually doing?
Imagine a bouncer at a club door. You hand him a card that says something like:
1:mysecretsession
The bouncer does two things:
- Checks that everything after the
1:is made only of letters (no funny symbols allowed). - Uses that value to look you up in a guest list (a database).
If your name is on the guest list, you get in. If your name matches a VIP's name, you might even get to see the VIP's stuff.
What went wrong?
The bouncer's "letters only" check had a blind spot. And the way your card gets turned into a database lookup was also sloppy — sloppy enough that if you could sneak in the right symbols, you could rewrite the guest list check itself to say "let everyone in."
Bug #1: The bouncer counts wrong
Here's the actual filter code:
const bytes = Buffer.from(session, 'utf8');
for (let i = 0; i < session.length; i++) {
const b = bytes[i];
const isLetter = (b>=0x41&&b<=0x5a)||(b>=0x61&&b<=0x7a)||b>=0x80;
if (!isLetter) throw new Error('invalid game session');
}
In plain English: "Take my session text, turn it into raw computer bytes, and check that every byte is a letter (or a special foreign-looking byte)."
Here's the catch. Computers store text as "code units," but they store bytes differently depending on the character. A plain English letter like a is exactly 1 byte. But an accented letter, or a symbol like ¡, needs 2 bytes to store — even though it still only counts as "1 character" in the text.
So if I write:
- 10 letters → 10 characters → 10 bytes. Everything lines up. Easy to check.
- 10 special 2-byte symbols → 10 characters → but 20 bytes! The counting is now out of sync.
The filter loop only checks as many bytes as there are characters — not as many bytes as there actually are. So if I stack up enough 2-byte symbols at the front, the "extra" bytes hiding at the end of the buffer just... never get checked. They slip through completely unexamined.
Analogy: Imagine a security guard who's told "check the first 10 people in line." If I sneak 10 people in wearing backpacks that are secretly stuffed with two people each, the guard checks the first 10 backpacks and waves everyone through — never noticing that 20 actual people just walked in, and the last 10 were never looked at.
That's exactly what I did — except my "people in backpacks" were harmless-looking symbols (¡¡¡¡¡¡...), and the "people who snuck through unchecked" were forbidden characters like ", {, }, and ,.
Bug #2: Copy-pasting text straight into a database question
Once my forbidden symbols made it through, here's what the app did next:
JSON.parse(`{"id":${id}, "session":{"session":"${session}"}}`)
This builds a little data package and hands it to the database. But look closely — it's just gluing my raw text straight into that package, with no safety checks. It's like a form letter that says:
"Dear zahin, welcome to the club."
If I fill in zahin with Bob, and also delete everyone's membership. Sincerely, Bob, and the system copies it in without checking what I wrote, I've just turned a harmless form letter into a command.
That's what I did with the JSON package. Instead of putting in a normal session value, I put in text that:
- Closed off the sentence early,
- Added a brand new instruction,
- And that new instruction said: "the database check should just be
{}" — which, to a database, basically means "no check at all, let it through."
The final package looked something like this (simplified):
{ "id": {...junk...}, "session": {} }
And the app then ran a database lookup like:
Users.findOne({ where: {} })
Which basically says: "find me a user... any user, I don't care which." The database happily returned the very first user in its list — and it turned out that user's data was the flag.
Putting it all together — the actual "key"
Instead of hand-typing a wall of ¡ symbols, I generated the exact payload with a one-liner:
Command:
python3 -c "print('1:'+'¡'*28+'x\"},\"session\":{},\"id\":{\"z\":\"')"
Output:
1:¡¡¡¡¡¡¡¡¡¡¡¡¡¡¡¡¡¡¡¡¡¡¡¡¡¡¡¡x"},"session":{},"id":{"z":"
- The wall of
¡symbols is the "decoy backpacks" — they eat up the filter's attention. - Everything after that (
x"},"session":{},"id":{"z":") is the actual forbidden content, riding through unchecked. - I needed exactly enough decoy symbols (28 of them) to match the length of my real payload — no more, no less — so the math lined up perfectly.
I pasted that output straight into the challenge's input field and submitted it.
Result: FLAG{N3w_L3v31_Unl0cked}
Why this matters in real life
This isn't just a fun puzzle. This exact combination of mistakes — (1) a length check that doesn't match how the data is actually measured, and (2) blindly gluing user input into a database query — shows up in real production apps too. If it did, an attacker could:
- Log in as any user without a password.
- Read another person's private data.
- In worse cases, even change or delete data.
How to actually fix it (for developers reading this)
- When checking bytes, count actual bytes — not "characters." (
for (let i = 0; i < bytes.length; i++)) - Never hand-glue user text into a JSON string. Use safe tools that escape everything properly (like
JSON.stringify). - Never let raw user input become a database filter directly. Always double-check what shape and type it's allowed to be.
Final thoughts
This was a fun one because the bug wasn't some obscure library flaw — it was really just "the code counted two different things as if they were the same thing," and that one small mismatch cracked the whole filter wide open. A good reminder that security bugs are often just... a math mistake in disguise.
Thanks for reading — feel free to try Dojo #54 yourself before reading spoilers like this one! 🚩



Top comments (1)
Việc lách qua bộ lọc chữ cái bằng các byte ẩn thực sự là một bài học kinh điển về việc quá tin tưởng vào regex mà quên mất cách xử lý các ký tự điều khiển hoặc Unicode không hiển thị. Mình từng gặp một trường hợp tương tự khi làm việc với hệ thống validation cho tên người dùng, nơi mà các ký tự zero-width space đã làm hỏng hoàn toàn logic kiểm tra độ dài và định dạng của database. Một kinh nghiệm xương máu là luôn phải chuẩn hóa (normalize) chuỗi về dạng NFC hoặc dùng các thư viện chuyên dụng để loại bỏ các ký tự không in được trước khi đưa qua bất kỳ bước filter nào. Chúc mừng bạn đã tìm ra lỗ hổng thú vị này.