Secure Communication (Advanced)
0x00 Basic Informations
View notion version: Notion
Attachment
|
|
Service
nc chal.polyuctf.com 35075http://chal.polyuctf.com:35075
Hint
- I don’t think anyone should sneak into the communication, so I disabled all internet access.
|
|
0x01 First Connection
First, I used nc to connect to the service.
|
|
It’s seems like it keeps the same custom RSA-wrapped command channel with Secure Communication so I modified the solved script for this challenge.
|
|
Fortunately, its works!
0x02 Decompile the executable
Due to this is the advanced version of Secure Communication, you can literally decompile it by using completely same logic with last challenge!
|
|
-
index.js
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 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312// @bun import * as a from "crypto"; var f = null; async function S() { let { publicKey: s, privateKey: r } = a.generateKeyPairSync("rsa", { modulusLength: 4096, publicExponent: 65537, publicKeyEncoding: { type: "spki", format: "der" }, privateKeyEncoding: { type: "pkcs8", format: "pem" }, }), n = a.createPublicKey({ key: s, format: "der", type: "spki" }), p = a.createPrivateKey({ key: r, format: "pem", type: "pkcs8" }); f = { publicKey: n, privateKey: p }; } async function B() { if (!f) await S(); return ( f.publicKey .export({ type: "spki", format: "der" }) .toString("base64") .match(/.{1,64}/g)?.join(` `) || "" ); } var d = null; async function k(s) { let r = s.replace(/\s/g, ""), n = Buffer.from(r, "base64"); d = a.createPublicKey({ key: n, format: "der", type: "spki" }); } async function o(s) { if (!d) throw Error("No user public key imported"); let r = Buffer.from(s, "utf8"); return a .publicEncrypt( { key: d, padding: a.constants.RSA_PKCS1_OAEP_PADDING, oaepHash: "sha512", }, r, ) .toString("base64"); } async function w(s) { if (!f) throw Error("Server keypair not generated"); let r = Buffer.from(s, "base64"); return a .privateDecrypt( { key: f.privateKey, padding: a.constants.RSA_PKCS1_OAEP_PADDING, oaepHash: "sha512", }, r, ) .toString("utf8"); } import { Database as $ } from "bun:sqlite"; var u = new $(":memory:"); u.run( "CREATE TABLE users (id INTEGER PRIMARY KEY, username STRING, pin INTEGER, admin BOOLEAN)", ); var b = Date.now(); b -= b % 5000; var A = u.prepare("INSERT INTO users (username, pin, admin) VALUES (?, ?, ?)"); A.run("admin", BigInt(b) ** 2n, !0); async function K(s, r, n) { u.prepare("INSERT INTO users (username, pin, admin) VALUES (?, ?, ?)").run( s, r, !1, ); } async function v(s, r) { let p = u.prepare("SELECT * FROM users WHERE username = ?").get(s); if (!p) return !1; return p.pin === r; } function x(s) { let n = u.prepare("SELECT * FROM users WHERE username = ?").get(s); if (!n) return !1; return n.admin; } import { deserialize as R } from "bun:jsc"; var { embeddedFiles: g } = globalThis.Bun; import { parseArgs as O } from "util"; import { readdir as h, rm as M } from "fs/promises"; var P = Bun.env.PORT ? parseInt(Bun.env.PORT) : 8080, { values: U, positionals: E } = O({ args: Bun.argv, options: {}, strict: !0, allowPositionals: !0, }); (async () => { if (E.length >= 3 && E[2] === "update") { console.log("Updating..."); try { let l = await fetch("https://192.168.0.10:8443/"); if (l.ok) { let c = await l.arrayBuffer(); await Bun.write(Bun.argv[0], Buffer.from(c)); } } catch { } (console.log("Update successful."), process.exit(0)); } let s = setTimeout(async () => { (console.log("My Public Key:", (await B()).replace(/\s/g, "")), process.stdout.write("Your Public Key: ")); }, 500), r = 0, n = "", p = !1, m = null; for await (let l of console) { if (s && r === 0) clearTimeout(s); if (l.includes("HTTP")) { ((r = -1), (m = await Bun.connect({ hostname: "127.0.0.1", port: P, socket: { data(c, e) { process.stdout.write(e); }, open(c) { c.write( l + ` `, ); }, close(c, e) { ((m = null), console.log("Connection closed.", e), process.exit(0)); }, drain(c) { }, error(c, e) { }, connectError(c, e) { }, end(c) { }, timeout(c) { }, }, }))); continue; } switch (r) { case -1: if (m) m.write( l + ` `, ); break; case 0: try { (await k(l.trim()), console.log("Public Key imported successfully."), (r = 1)); } catch { (console.log("Invalid Public Key. Please try again."), process.stdout.write("Your Public Key: ")); } break; case 1: try { await w(l); } catch { console.log("Echo:", l); continue; } case 2: let c = await w(l); try { let e = R(Buffer.from(c, "base64")); if (e.command !== "login") console.log("Received message:", Bun.JSON5.stringify(e)); if (e.command === "exit") (console.log("Exiting..."), process.exit(0)); else if (e.command === "register") try { if (e.username && e.pin) (await K(e.username, e.pin, !1), console.log(await o(`Registered user: ${e.username}`))); else console.log(await o("Missing username or pin")); } catch { console.log(await o("Registration Error")); } else if (e.command === "login") try { if (e.username && e.pin) if (await v(e.username, parseInt(e.pin))) ((n = e.username), (p = x(n)), console.log(await o(`Login successful. Welcome, ${n}!`))); else console.log(await o("Login failed. Invalid credentials.")); else console.log(await o("Missing username or pin")); } catch { console.log(await o("Login Error")); } else if (e.command === "ping") if (n) if (e.ip) try { let t = Bun.spawnSync({ cmd: ["ping", "-c", "1", e.ip], stdout: "pipe", stderr: "pipe", }); if (t.exitCode === 0) for (let i of t.stdout.toString().split(` `)) console.log(await o(i)); else for (let i of t.stderr.toString().split(` `)) console.log(await o(i)); } catch { console.log(await o("Ping Command Error")); } else console.log(await o("Missing IP address")); else console.log(await o("Please login first.")); else if (e.command === "reset") { for (let t of await h("/tmp")) await M(`/tmp/${t}`, { recursive: !0 }); if (g.length === 0) try { for (let t of await h(import.meta.dir + "/public")) (console.log("Resetting file:", t), await Bun.write( `/tmp/${t}`, Bun.file(`${import.meta.dir}/public/${t}`), )); } catch { } else for (let t of g) { let i = t.name; if (!i.startsWith("public/")) continue; ((i = i.replace(/^public\//, "")), await Bun.write(`/tmp/${i}`, await t.arrayBuffer())); } console.log(await o("Environment reset completed.")); } else if (e.command === "start") { if (g.length === 0) try { for (let t of await h(import.meta.dir + "/public")) (console.log("Resetting file:", t), await Bun.write( `/tmp/${t}`, Bun.file(`${import.meta.dir}/public/${t}`), )); } catch { } else for (let t of g) { let i = t.name; if (!i.startsWith("public/")) continue; ((i = i.replace(/^public\//, "")), await Bun.write(`/tmp/${i}`, await t.arrayBuffer())); } (Bun.serve({ port: P, async fetch(t) { let i = new URL(t.url).pathname, y = Bun.file(i === "/" ? "/tmp/index.html" : `/tmp${i}`); if (await y.exists()) return new Response(y); return new Response("Not found", { status: 404 }); }, }), console.log(await o(`Server started on port ${P}.`))); } else if (e.command === "upload") if (!n) console.log(await o("Please login first.")); else if (!p) console.log(await o("Admin privileges required.")); else if (e.files && Array.isArray(e.files)) for (let t of e.files) { let i = t.content; if ( t.name.match(/^[a-zA-Z0-9_\-\.]html$/) || i.type === "text/html" ) { let y = `/tmp/${t.name}`; (await Bun.write(y, await i.content), console.log(await o(`File uploaded: ${t.name}`))); } else console.log(await o(`Skipping invalid file type: ${t.name}`)); } else console.log(await o("No files provided for upload.")); else if (e.command === "update") if (!n) console.log(await o("Please login first.")); else if (!p) console.log(await o("Admin privileges required.")); else if ( Bun.spawnSync({ cmd: [process.execPath, "update"], stdout: "pipe", stderr: "pipe", env: e.env || process.env, }).exitCode === 0 ) (console.log( await o("Update successful. Please restart the application."), ), process.exit(0)); else console.log(await o("Update Error")); else console.log(await o("Unknown command")); } catch { console.log("Deserialization Error"); } break; } } })();
0x03 Static analysis
By investigation, the server-side appears following behavior:
-
The service is not a plain-text command server. It first performs an RSA-OAEP handshake with SHA-512: the server prints its public key, the client submits its own public key, and all subsequent commands are exchanged as encrypted base64 strings.
-
Incoming commands are decrypted and then deserialized with
bun:jsc, which means the server accepts not only ordinary JSON-like data, but also Bun-native serialized objects. -
User data is stored in an in-memory SQLite database. An initial admin account is created at startup.
-
The
registerhandler calls:INSERT INTO users (username, pin, admin) VALUES (?, ?, ?)with.run(username, pin, false).1 2 3 4 5 6 7 8 9 10var A = u.prepare("INSERT INTO users (username, pin, admin) VALUES (?, ?, ?)"); A.run("admin", BigInt(b) ** 2n, !0); async function K(s, r, n) { u.prepare("INSERT INTO users (username, pin, admin) VALUES (?, ?, ?)").run( s, r, !1, ); }However, because Bun SQLite expands an array passed as the first argument across all placeholders, an attacker can supply
usernameas an array and setadmin = true. -
After login, the service tracks the current username and whether the session has admin privileges.
1 2 3 4 5 6 7 8 9 10 11 12else if (e.command === "login") try { if (e.username && e.pin) if (await v(e.username, parseInt(e.pin))) ((n = e.username), (p = x(n)), console.log(await o(`Login successful. Welcome, ${n}!`))); else console.log(await o("Login failed. Invalid credentials.")); else console.log(await o("Missing username or pin")); } catch { console.log(await o("Login Error")); } -
The
startcommand launches an HTTP server on the same challenge port and serves files from/tmp.-
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} else if (e.command === "start") { if (g.length === 0) try { for (let t of await h(import.meta.dir + "/public")) (console.log("Resetting file:", t), await Bun.write( `/tmp/${t}`, Bun.file(`${import.meta.dir}/public/${t}`), )); } catch { } else for (let t of g) { let i = t.name; if (!i.startsWith("public/")) continue; ((i = i.replace(/^public\//, "")), await Bun.write(`/tmp/${i}`, await t.arrayBuffer())); } (Bun.serve({ port: P, async fetch(t) { let i = new URL(t.url).pathname, y = Bun.file(i === "/" ? "/tmp/index.html" : `/tmp${i}`); if (await y.exists()) return new Response(y); return new Response("Not found", { status: 404 }); }, }), console.log(await o(`Server started on port ${P}.`))); }
-
-
The
uploadcommand is restricted to admin users. It accepts an array of files and writesawait content.contentdirectly to/tmp/<name>withBun.write(...).-
Click to expend
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17} else if (e.command === "upload") if (!n) console.log(await o("Please login first.")); else if (!p) console.log(await o("Admin privileges required.")); else if (e.files && Array.isArray(e.files)) for (let t of e.files) { let i = t.content; if ( t.name.match(/^[a-zA-Z0-9_\-\.]html$/) || i.type === "text/html" ) { let y = `/tmp/${t.name}`; (await Bun.write(y, await i.content), console.log(await o(`File uploaded: ${t.name}`))); } else console.log(await o(`Skipping invalid file type: ${t.name}`)); } else console.log(await o("No files provided for upload."));
-
-
Since the command channel uses
bun:jsc, an attacker can serialize aBun.file("/flag")object and smuggle it into theuploadhandler, causing the server to copy/flaginto the web-accessible/tmpdirectory. -
Internet access being disabled only blocks outbound exfiltration; it does not prevent local file-copy abuse through the built-in upload and HTTP serving logic.
Thus, we can figure out the whole exploit chain.
0x04 Exploit chain
- Complete the RSA-based handshake so the client can communicate with the service correctly.
- Abuse the SQLite array-binding behavior in
registerto create a user withadmin = true. - Log in as the newly created admin user.
- Invoke the admin-only
startcommand to launch the built-in HTTP file server. - Send an
uploadpayload containing abun:jscserializedBun.file("/flag")object. - Make the server copy the contents of
/flaginto/tmp/flag.html. - Retrieve
/flag.htmlover HTTP and read the flag.
0x05 Flow
-
The registration function:
1u.prepare("INSERT INTO users (username, pin, admin) VALUES (?, ?, ?)").run(username, pin, false); -
With Bun sqlite, if the first argument is an array, it is expanded across the placeholders. The expected hard coded admin privilege was bypassed.
1{"command":"register","username":["myuser",1337,true],"pin":1337} -
And causes sqlite to insert the following data:
1 2 3username = "pwnuser" pin = 31337 admin = trueand leads to vertical privilege escalation.
-
By sending this command:
1{"command":"start"}The server-side launches a file server that servers files from
/tmpon the same port. -
The upload handler accepts deserialized objects and writes
await content.contentdirectly withBun.write(...). Usingbun:jsc, serialize a payload like:1 2 3 4 5 6 7 8 9 10 11 12{ command: "upload", files: [ { name: "flag.html", content: { type: "text/html", content: Bun.file("/flag") } } ] } -
After deserialization server-side,
content.contentis still aBun.file("/flag")blob-like objet, so:1await Bun.write("/tmp/flag.html", await content.content) -
Ultimately get the flag in
1http://chal.polyuctf.com:35075/flag.html
0x06 Script
|
|
0x07 Flag
|
|