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 (0)