PwnFortID 2026
Editorial Board
A service runs submitted text through a real grammar checker, then copies it into an RWX page and jumps in. The overflow and the shellcode both have to read as clean English, so the shellcode is built out of real words.
What this one teaches
- How an unbounded
rep movsbinto a fixed stack frame overwrites the saved return address, and why a mapped RWX page turns that into code execution. - How to read x86 opcodes as a byte stream when every byte must also be a plain-text character, so the shellcode is written out of letters, a few separators, and the minimal punctuation a grammar checker tolerates.
- Why a content filter (grammar, spelling, style) says nothing about whether an input is safe to execute as code.
Task description
We are a discerning editorial board committed to publishing clear, polished, human-quality writing. No low-effort AI slop, no noisy drafts, and no suspicious manuscripts. Submit your article for review; if it passes our anti-slop desk, we will typeset it, publish it, and let the prose speak for itself. ✍️
What the service does
The handout is a 32-bit, non-PIE, stripped ELF (editorial_board) plus the validate.py it shells out to. I confirmed the shape straight from the file header: e_ident[EI_CLASS] = 1 (32-bit), e_machine = 3 (i386), e_type = 2 (ET_EXEC, so the binary loads at a fixed address with no PIE randomisation).
On connection the program prints a SHA-256 of the active validate.py. The handout’s copy and the solution/ copy hash to the same 57d0eb4d4860..., so the validator on the live box is the one in the handout and the gates can’t have been quietly loosened. It then maps a fixed read-write-execute page at 0x656c6000, fills it with 0xff, and reads up to 900 bytes into a stack buffer.
Before doing anything with that buffer it runs two gates:
- an in-binary charset check: length 640 to 900, only letters, spaces, and
. , ; : ' - ? !, ending in.,?, or!; - a subprocess call to
validate.py --quiet --stdin, which runs the text through LanguageTool (an open-source grammar and style checker) and additionally requires at least 90 words, at least 4 sentences, a repetition ratio of 0.1 or under, an average sentence length of 32 words or under, zero spelling findings, 2.2 or fewer style findings per 100 words, and an editorial score of 88 or above.
Both gates come straight from validate.py. The constants at the top of the file read MAX_INPUT = 900, MIN_INPUT = 640, ALLOWED = set(string.ascii_letters + " .,;:'-?!"), MIN_WORDS = 90, MIN_SENTENCES = 4, MIN_SCORE = 88.0, MAX_ISSUES_PER_100 = 2.2, MAX_SPELLING_PER_100 = 0.0, MAX_REPETITION_RATIO = 0.1, MAX_AVG_SENTENCE_WORDS = 32.0.
Only after both gates pass does the binary copy the manuscript into the RWX page and jump into a “typeset” routine.
Finding the overflow
The typeset routine copies the full manuscript into a 601-byte stack frame with an unbounded rep movsb. There is no canary and the destination page has no NX, so the four bytes at manuscript offset 605 (601 for the frame, 4 for the saved frame pointer) land on the saved return address.
The binary’s own code names the address it expects there. The constant 0x656c6261, which spells able in little-endian ASCII, is loaded into a register before the copy, and that value is the RWX page plus offset 609 (0x656c6261 - 0x656c6000 = 0x261 = 609). Both constants are literally present in the file: the bytes 00 60 6c 65 (0x656c6000, the mmap address) appear once, and 61 62 6c 65 (able) appears twice. So the exploit only has to:
- end the manuscript’s opening section with a word whose last four letters are
able, so bytes 605 to 608 readableand redirect execution into the RWX page at offset 609; - make everything from offset 609 onward, now running as code, still read as grammatically clean English, because that same manuscript is what gets validated.
At the moment of the hijack the registers are a gift: eax = 0, edx = 0, ecx = edi = 0x656c6261 (a pointer into the page, right at the entry point), esi points at the writable string /bin/sha256sum (whose first seven bytes spell /bin/sh, and the string is present in the binary at file offset 0x3088), and ebx points at a zeroed scratch dword. Everything the shellcode needs is already sitting in registers when it starts.
What a stack buffer overflow and a return address are, for a reader coming from another specialism
When a function runs, the CPU keeps its local variables and bookkeeping in a contiguous slice of the stack called the stack frame. One of the bookkeeping values is the return address: the location in the caller’s code to jump back to when this function finishes. A ret instruction pops that value off the stack and jumps to it.
rep movsb is a block copy: “copy ecx bytes from esi to edi.” Here the count comes from the manuscript length, and the destination is a fixed 601-byte frame, so a manuscript longer than the frame keeps writing past the end of the frame and over the saved return address that sits just above it. Overwrite those 4 bytes and you choose where ret goes. No memory-corruption magic beyond that: the copy simply does not stop where the buffer does.
A classic exploit would then need somewhere to put code and permission to run it. That is what the RWX page provides (next block).
Why a read-write-execute page is the whole game
Modern systems enforce W^X: a page of memory is writable or executable, not both, so attacker bytes written into a buffer cannot also be run as code (this is NX, “no-execute”). This binary deliberately maps one page at 0x656c6000 as read, write, and execute at once, fills it with 0xff, then copies the attacker’s manuscript into it. A writable-and-executable region that holds attacker bytes is a direct code-execution primitive: redirect the return address into it and the CPU runs whatever was copied there. The grammar gate is the only thing standing between the attacker and that page, which is why the shellcode has to survive the grammar check.
Writing shellcode that reads as English
The generator for this manuscript ships in the handout as solution/generate.py, so the construction below is read out of that source rather than reconstructed from a disassembly. LanguageTool’s own leniency is what makes a printable, English-shaped decoder possible, and generate.py encodes exactly which bytes it treats as safe:
- its separator set is
SEPS = {0x20, 0x2d, 0x3a, 0x3b}, that is space,-,:,;, the punctuation that can sit between tokens without tripping a whitespace or quoting rule; - its letter set is
0x41..0x5aand0x61..0x7b, the ASCII letters; - any run of two or more adjacent letters has to be a real dictionary word (
is_cleanchecks each multi-letter run against/usr/share/dict/american-english), while single letters separated by one of those separators are always allowed, because LanguageTool never flags an isolated single letter as a spelling mistake.
The shellcode is built from three kinds of token, all emitted by generate.py:
- The write primitive is
and [edi+disp8], eax, which encodes as the three bytes0x21 0x47 disp, reading as!Gthen a one-byte displacement. The page starts filled with0xff, and0xff AND x = x, so this instruction deposits an arbitrary dword atedi + dispwithout building a pointer first, becauseedialready points into the page. Ingenerate.pythis is literallycode += bytes([0x21, 0x47, declen + 4*i]). - Value building drives
eaxto the dword each write needs with a chain ofsub eax, imm32instructions (0x2dthen four bytes) placed before that write. Each 4-byte immediate is picked so its bytes are all in the allowed set and any multi-letter run is a real word, found bygenerate.py’ssolve/dp_solvesearch. Every token in the chain reads as a plain word or a short separator group. - The stub is the constant
STUB = bytes.fromhex("89f3885307cd8090")ingenerate.py:mov ebx, esi; mov [ebx+7], dl; int 0x80; nop. That isexecve("/bin/sh", NULL, NULL)using the register gifts.mov ebx, esipointsebxat/bin/sha256sum;mov [ebx+7], dlwrites the0indlover the eighth character, truncating the string in place to/bin/sh;edx = 0also serves as bothargvandenvp;int 0x80witheax = 0x0bis theexecvesyscall. These bytes are not printable, so they are never in the manuscript. The twoand [edi+disp], eaxwrites deposit them (8 bytes, 2 dwords) into the page’s still-0xfftail, right after the decoder.
ecx is zeroed just before the stub with push edx; pop ecx, emitted as the two capital letters RY (code += bytes([0x52, 0x59])), and the final value-building chain leaves eax = 0x0b. A trailing . (0x2e) ends the sentence for the charset gate and is harmless as code (a cs: segment prefix on whatever follows).
How "printable" or "English" shellcode is possible at all
x86 has no requirement that instruction bytes be unprintable. Many opcodes happen to fall in the ASCII range: 0x2d is sub eax, imm32, 0x21 is and r/m, r32, 0x52/0x59 are push edx/pop ecx, and so on. Writing shellcode under a charset constraint means choosing a sequence of instructions whose opcodes and operands all land in the allowed bytes while still computing what you want. The usual trick, used here, is a two-stage payload: a short decoder made only of allowed bytes writes the real (unprintable) instructions somewhere executable, then execution falls through into them. The and-into-a-0xff-page write is the decoder’s way of depositing arbitrary bytes without any unprintable opcode of its own.
The payload
The full manuscript is 678 bytes. It reads as an ordinary paragraph of writing advice, with the opening engineered so that able lands on the return address and the tail is the printable decoder:
We are a wee desk. Each day we read with real care. We beg you to stay kind toward all who might scan this page. A fine piece feels clear and warm and true. It shall not strain to look smart. It must avoid a cheap boast. We love short lines and simple terms. We seek a slow but gentle flow. Let one bright idea breathe and grow. Can you pin a thought in seven words? If yes, do that, then trim the rest by a firm hand. We never crave fluff or spin or the dull hum of a bot with no pulse. We want your sharp eye, your keen ear, your odd knack for a phrase that jolts the brain. Great prose may wake a glad able-i;m -r:p -b l -:v-K!GE-i:f;-t:-:-:v- -k;G-!GI-t q--B x- z-;J -RY.
The 69-byte decoder (everything from offset 609, the -i;m ... onward) deposits the stub at edi+0x45 and edi+0x49 (the two !G writes, visible as !GE and !GI in the text), zeroes ecx with RY, and falls into the stub. I checked the structure directly against manuscript.bin:
$ python3 -c "d=open('manuscript.bin','rb').read().rstrip(b'\r\n'); \
print(len(d), d[605:609], hex(int.from_bytes(d[605:609],'little')))"
678 b'able' 0x656c6261
Running the manuscript through validate.py’s structural pass (the part that needs no LanguageTool) reproduces the accepting numbers: 152 words, 16 sentences, a repetition ratio of 5/152 = 0.033 (gate: 0.1 or under), and an average of 9.50 words per sentence (gate: 32 or under). The editorial score follows from the same formula in validate.py:
score = 100 - issues_per_100*5 - spelling_per_100*10 - repetition*55 - max(0, avg-24)*0.6
With LanguageTool returning its two whitespace findings around the !-writes and zero spelling findings, that is issues_per_100 = 2*100/152 = 1.32 and score = 100 - 1.32*5 - 0 - 0.033*55 - 0 = 91.61, clearing the 88 gate. The structural numbers and the arithmetic I recomputed myself; the “two findings, zero spelling” part is LanguageTool’s verdict from the solve.
Against the live service the manuscript passes both gates, the typeset overflow redirects execution into the RWX page at offset 609, the decoder rebuilds the execve("/bin/sh") stub byte by byte, and the spawned shell (running as the ctf user) reads /flag.txt:
FortID{Sh3llc0d1n6_L1k3...
The flag theme, “shellcoding like Shakespeare,” lines up with the technique. It comes from the live run; no handout artifact echoes the exact string.
One delivery detail: the validator takes a few seconds and the service prints a “reviewing your manuscript” message in the meantime, so the client sends the manuscript, waits out that delay, and only then writes shell commands to the same connection.
Defence
A fixed-address RWX mapping plus any copy of attacker bytes into it is a code-execution primitive on its own, and an ASCII-only input filter does nothing against it. The fix is to clamp the copy length (or add a canary) so the typeset routine cannot walk off the end of its 601-byte frame, and to never map memory both writable and executable for untrusted content. Running the manuscript through a grammar and style checker is a red herring from a security point of view: a submission can be flawless English and valid machine code at the same time, and LanguageTool’s tolerance for single letters and light punctuation is exactly what makes a printable, English-shaped decoder buildable by hand here.
The flagReveal
FortID{Sh3llc0d1n6_L1k3_Sh4k3sp34r3_G3ts_7h3_Fl4G!_5b65cbc9ff}This CTF was possibly solved, and the writeup most likely written by an LLM. Apologies for inconsistencies.