Summary
RegExp.prototype[Symbol.replace] uses the length of a custom exec() result to allocate the replacement callback's argument array. On a 32-bit build, length = 2**32 - 2 makes the allocation count wrap to zero. Escargot then writes the first argument outside the zero-sized allocation.
The attached PoC triggers an eight-byte dynamic-stack-buffer-overflow in builtinRegExpReplace under ASan.
Affected Version
Tested on Escargot master commit 1f3079ea4b9688fa5edc777e6aacd180436eba3e using a 32-bit i686 Linux release build with ASan. The same affected code is present in v4.3.0.
Steps to Reproduce
Configure a 32-bit i686 build:
cmake -S . -B build-asan -G Ninja \
-DCMAKE_POLICY_VERSION_MINIMUM=3.5 \
-DCMAKE_C_COMPILER=i686-linux-gnu-gcc \
-DCMAKE_CXX_COMPILER=i686-linux-gnu-g++ \
-DCMAKE_C_FLAGS="-fsanitize=address -fno-omit-frame-pointer" \
-DCMAKE_CXX_FLAGS="-fsanitize=address -fno-omit-frame-pointer" \
-DCMAKE_EXE_LINKER_FLAGS="-fsanitize=address -Wl,--allow-multiple-definition" \
-DESCARGOT_ARCH=x86 \
-DESCARGOT_MODE=release \
-DESCARGOT_OUTPUT=shell \
-DESCARGOT_LIBICU_SUPPORT=OFF \
-DESCARGOT_THREADING=ON
cmake --build build-asan --parallel
Run the i686 binary on an i686 host, or with qemu-user:
ASAN_OPTIONS=abort_on_error=1:detect_leaks=0:halt_on_error=1 \
qemu-i386 -L /usr/i686-linux-gnu \
./build-asan/escargot \
poc/regexp-replace-capture-count-stack-oob-write-poc.js
Expected Result
Escargot should reject a capture count that cannot be represented safely, or allocate an argument array large enough for every later write.
Actual Result
=================================================================
==240==ERROR: AddressSanitizer: dynamic-stack-buffer-overflow on address 0x3fffd300 at pc 0x402b62ee bp 0x3fffd2b8 sp 0x3fffd2ac
WRITE of size 8 at 0x3fffd300 thread T0
#0 0x402b62ed in builtinRegExpReplace /src/src/builtins/BuiltinRegExp.cpp:489
#1 0x409cb9ab in Escargot::Value Escargot::NativeFunctionObject::processNativeFunctionCall<false, true>(Escargot::ExecutionState&, Escargot::Value const&, unsigned int, Escargot::Value*, Escargot::Optional<Escargot::Object*>) /src/src/runtime/FunctionObjectInlines.h:312
#2 0x409cb9ab in Escargot::NativeFunctionObject::call(Escargot::ExecutionState&, Escargot::Value const&, unsigned int, Escargot::Value*) /src/src/runtime/NativeFunctionObject.cpp:78
#3 0x409e475f in Escargot::Object::call(Escargot::ExecutionState&, Escargot::Value const&, Escargot::Value const&, unsigned int, Escargot::Value*) /src/src/runtime/Object.cpp:1332
#4 0x401c04a0 in builtinFunctionCall /src/src/builtins/BuiltinFunction.cpp:140
#5 0x409cb9ab in Escargot::Value Escargot::NativeFunctionObject::processNativeFunctionCall<false, true>(Escargot::ExecutionState&, Escargot::Value const&, unsigned int, Escargot::Value*, Escargot::Optional<Escargot::Object*>) /src/src/runtime/FunctionObjectInlines.h:312
#6 0x409cb9ab in Escargot::NativeFunctionObject::call(Escargot::ExecutionState&, Escargot::Value const&, unsigned int, Escargot::Value*) /src/src/runtime/NativeFunctionObject.cpp:78
#7 0x4046d0fc in Escargot::Interpreter::interpret(Escargot::ExecutionState*, Escargot::ByteCodeBlock*, unsigned int, Escargot::Value*) /src/src/interpreter/ByteCodeInterpreter.cpp:807
#8 0x404e4023 in Escargot::Script::execute(Escargot::ExecutionState&, bool, bool) /src/src/parser/Script.cpp:500
#9 0x40090662 in Escargot::ScriptRef::execute(Escargot::ExecutionStateRef*) /src/src/api/EscargotPublic.cpp:5067
#10 0x40bb610c in operator() /src/src/shell/Shell.cpp:947
#11 0x40bb610c in _FUN /src/src/shell/Shell.cpp:947
#12 0x400836f3 in operator() /src/src/api/EscargotPublic.cpp:1350
#13 0x400836f3 in _FUN /src/src/api/EscargotPublic.cpp:1351
#14 0x40a9b44b in Escargot::SandBox::run(Escargot::Value (*)(Escargot::ExecutionState&, void*), void*) /src/src/runtime/SandBox.cpp:111
#15 0x40088e4a in Escargot::Evaluator::executeFunction(Escargot::ContextRef*, Escargot::ValueRef* (*)(Escargot::ExecutionStateRef*, void*, void*), void*, void*) /src/src/api/EscargotPublic.cpp:1352
#16 0x40bba6df in Escargot::Evaluator::EvaluatorResult Escargot::Evaluator::executeImpl<Escargot::ContextRef, Escargot::ScriptRef*>(Escargot::ContextRef*, Escargot::ValueRef* (*)(Escargot::ExecutionStateRef*, Escargot::ScriptRef*), Escargot::ScriptRef*) /src/src/api/EscargotPublic.h:672
#17 0x40bba6df in execute<Escargot::ScriptRef*, evalScript(Escargot::ContextRef*, Escargot::StringRef*, Escargot::StringRef*, bool, bool)::<lambda(Escargot::ExecutionStateRef*, Escargot::ScriptRef*)> > /src/src/api/EscargotPublic.h:645
#18 0x40bba6df in evalScript /src/src/shell/Shell.cpp:947
#19 0x4007d1e1 in main /src/src/shell/Shell.cpp:1319
#20 0x3ec4e294 (/lib/libc.so.6+0x23294)
#21 0x3ec4e357 in __libc_start_main (/lib/libc.so.6+0x23357)
#22 0x4007f6b6 in _start (/build/escargot+0x7f6b6)
Address 0x3fffd300 is located in stack of thread T0
SUMMARY: AddressSanitizer: dynamic-stack-buffer-overflow /src/src/builtins/BuiltinRegExp.cpp:489 in builtinRegExpReplace
Shadow bytes around the buggy address:
0x27fffa10: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00
0x27fffa20: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00
0x27fffa30: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00
0x27fffa40: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00
0x27fffa50: 00 00 00 00 00 00 00 00 00 00 00 00 ca ca ca ca
=>0x27fffa60:[cb]cb cb cb 00 00 00 00 00 00 00 00 00 00 00 00
0x27fffa70: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00
0x27fffa80: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00
0x27fffa90: 00 00 f1 f1 f1 f1 01 f2 f8 f2 f8 f2 f8 f2 01 f2
0x27fffaa0: 01 f2 01 f2 01 f2 01 f2 01 f2 01 f2 01 f2 01 f2
0x27fffab0: 01 f2 01 f2 01 f2 01 f2 01 f2 01 f2 01 f2 01 f2
Shadow byte legend (one shadow byte represents 8 application bytes):
Addressable: 00
Partially addressable: 01 02 03 04 05 06 07
Heap left redzone: fa
Freed heap region: fd
Stack left redzone: f1
Stack mid redzone: f2
Stack right redzone: f3
Stack after return: f5
Stack use after scope: f8
Global redzone: f9
Global init order: f6
Poisoned by user: f7
Container overflow: fc
Array cookie: ac
Intra object redzone: bb
ASan internal: fe
Left alloca redzone: ca
Right alloca redzone: cb
==240==ABORTING
qemu: uncaught target signal 6 (Aborted) - core dumped
Aborted
Root Cause
The custom exec() object controls result.length. The code converts it to size_t, subtracts one, and then adds three without checking whether the addition wraps:
Value builtinRegExpReplace(
ExecutionState& state,
Value thisValue,
size_t argc,
Value* argv,
Optional<Object*> newTarget)
{
// ...
size_t nCaptures =
result->get(
state,
ObjectPropertyName(state.context()->staticStrings().length))
.value(state, result)
.toLength(state)
- 1;
// ...
size_t replacerArgsSize = nCaptures + 3;
if (functionalReplace) {
if (namedCaptures.isUndefined()) {
// On 32-bit, nCaptures + 3 wraps to zero.
replacerArgs =
ALLOCA(sizeof(Value) * (nCaptures + 3), Value);
} else {
replacerArgs =
ALLOCA(sizeof(Value) * (nCaptures + 4), Value);
replacerArgsSize++;
replacerArgs[nCaptures + 3] = namedCaptures;
}
// First write outside the zero-sized allocation.
replacerArgs[0] = matched;
}
// ...
}
For result.length = 2**32 - 2, nCaptures becomes 0xfffffffd on i686. nCaptures + 3 wraps to zero, but the code still writes replacerArgs[0]. The following loop can perform more out-of-bounds writes. The trigger does not need to create billions of properties because missing numeric properties return undefined.
Suggested Fix
Check that nCaptures is no greater than SIZE_MAX - 4 before adding the fixed arguments or multiplying by sizeof(Value). Reject the replacement if the full argument count or byte size cannot be represented. Use the checked count for the allocation and every later index.
regexp-replace-capture-count-stack-oob-write-poc.js
Summary
RegExp.prototype[Symbol.replace]uses thelengthof a customexec()result to allocate the replacement callback's argument array. On a 32-bit build,length = 2**32 - 2makes the allocation count wrap to zero. Escargot then writes the first argument outside the zero-sized allocation.The attached PoC triggers an eight-byte
dynamic-stack-buffer-overflowinbuiltinRegExpReplaceunder ASan.Affected Version
Tested on Escargot master commit
1f3079ea4b9688fa5edc777e6aacd180436eba3eusing a 32-bit i686 Linux release build with ASan. The same affected code is present in v4.3.0.Steps to Reproduce
Configure a 32-bit i686 build:
Run the i686 binary on an i686 host, or with qemu-user:
Expected Result
Escargot should reject a capture count that cannot be represented safely, or allocate an argument array large enough for every later write.
Actual Result
Root Cause
The custom
exec()object controlsresult.length. The code converts it tosize_t, subtracts one, and then adds three without checking whether the addition wraps:For
result.length = 2**32 - 2,nCapturesbecomes0xfffffffdon i686.nCaptures + 3wraps to zero, but the code still writesreplacerArgs[0]. The following loop can perform more out-of-bounds writes. The trigger does not need to create billions of properties because missing numeric properties returnundefined.Suggested Fix
Check that
nCapturesis no greater thanSIZE_MAX - 4before adding the fixed arguments or multiplying bysizeof(Value). Reject the replacement if the full argument count or byte size cannot be represented. Use the checked count for the allocation and every later index.regexp-replace-capture-count-stack-oob-write-poc.js