DEV Community

Zahin Shahriar
Zahin Shahriar

Posted on

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 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
Enter fullscreen mode Exit fullscreen mode

The bouncer does two things:

  1. Checks that everything after the 1: is made only of letters (no funny symbols allowed).
  2. 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');
}
Enter fullscreen mode Exit fullscreen mode

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}"}}`)
Enter fullscreen mode Exit fullscreen mode

Challenge input box showing the JSON.parse template code

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:

  1. Closed off the sentence early,
  2. Added a brand new instruction,
  3. 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": {} }
Enter fullscreen mode Exit fullscreen mode

And the app then ran a database lookup like:

Users.findOne({ where: {} })
Enter fullscreen mode Exit fullscreen mode

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.

discryption

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)

  1. When checking bytes, count actual bytes — not "characters." (for (let i = 0; i < bytes.length; i++))
  2. Never hand-glue user text into a JSON string. Use safe tools that escape everything properly (like JSON.stringify).
  3. 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)