How I Broke a "Letters Only" Filter Using Invisible Bytes (YesWeHack Dojo #54 Write-up)
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 b
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! π©
Originally published by Dev.to Security. Aggregated on AIWithGhost for educational purposes β full credit and traffic to the original publisher.

