10-25: Ghost Whisper
Writeup for Ghost Whisper (Web) - YesWeHack Dojo (2025) 💜
Description
A mysterious website lets you whisper to ghosts. But can you shatter the veil of silence and make your own voice heard?
Solution
In this writeup, we'll review the latest YesWeHack Dojo challenge, created by Brumens 🎃
Follow me on Twitter and LinkedIn (and everywhere else 🔪) for more hacking content! 🥰
Source code review
setup.py
import os
os.chdir('tmp')
os.mkdir('templates')
os.environ["FLAG"] = flag
with open('templates/index.html', 'w') as f:
f.write('''
<!DOCTYPE html>
<html lang="en">
SNIPPED
</html>
'''.strip())After snipping the HTML, there isn't much code left in the setup script. We know the flag is in an environment variable and the app uses templates, maybe SSTI?
app.py
All single quotes (
') in our input are replaced with underscores (_)Input is normalised using "NFKC" normalisation*
Input is inserted into an
echocommand, which is piped tohexdumpInput and [hex-converted] output of the command are rendered as a template
*NFKC (compatibility fold + canonical compose) collapses many visually-similar/codepoint-distinct characters (full-width forms, ligatures, some superscripts etc) into a standardised form. Sometimes Unicode normalisation can result in overflows/truncation, leading to useful character injections 👀
Testing functionality
How cute is that ghost?! 👻 Let's start by entering a random string as an input 😺
We'll see the hex output from the command is printed alongside the normalised message (separated by a |)
Unhex those characters. We'll find our original input. It's jumbled because hexdump prints 16-bit words by default, and reverses each pair on little endian systems.
Let's make a short script to explore further.
We'll see that submitting 'meow' will trigger the replacement resulting in _meow_. This is important because if we wanted to inject into the command, we would want to close off the existing quote.
Command Injection
If we can perform command injection, decoding the hexdump output will be trivial. Unfortunately, the developer used single quotes:
Instead of double quotes:
Yep, our input is treated as literal string. We need to find a way to inject a single quote.
We need to be careful not to break the command syntax, try and input: meow' $FLAG '
It works, we could even comment out the hexdump part with meow' $FLAG '' #
We have confirmed the command injection is easy to exploit, now we need to bypass the filter.
Unicode Overflow
Unicode codepoint truncation - also called a Unicode overflow attack - happens when a server tries to store a Unicode character in a single byte. Because the maximum value of a byte is 255, an overflow can be crafted to produce a specific ASCII character.
Portswigger: Bypassing character blocklists with Unicode overflows Shazzer: Unicode table
In other words; maybe we can find a Unicode character which truncates to a single quote when normalised with NFKC 🤔 Here's a quick fuzzing script:
It finds a single quote Unicode character that looks just like the ASCII version to the untrained eye.
We can easily test this by converting to hex. Normal quote:
Unicode quote:
We will supply the payload: meow' $FLAG '' #
It works 🙏 Now to repeat it against the real challenge.
Flag: FLAG{Gh0s7_S4y_BOOO000oooo}
Remediation
Call commands without shell (swap
os.popenwithsubprocess.run) so user input can't break out or inject new commandsSpecify allowed characters instead of filtering bad characters
Perform validation checks after processing (normalisation)
Summary (TLDR)
This was a cute Halloween challenge featuring a basic command injection vulnerability 🎃 Things were complicated slightly by the presence of a filter which restricted the use of the single quote characters needed to escape the string 👻 Luckily for us the NFKC normalisation step introduced a second vulnerability; a Unicode overflow 🦇 Since the character validation occurred before the normalisation, sending a specially crafted Unicode character was sufficient to bypass the filter 😱
Last updated