Posted on ::

tl;dr

A loadable kernel module on a rooted Pixel 6 reads PUBG Mobile's UE4 object graph straight out of process memory. No Frida injection, no ptrace, no /proc/*/mem. The game process never sees a single byte of ours. ACE (Tencent's anti-cheat) does not see the read path at all. It does see the on-screen overlay we draw with the result. That is a different fight.

The setup

The target is com.tencent.ig (PUBG Mobile global build), running on a Pixel 6 (oriole, GKI 5.10). The game ships with ACE / MTP, Tencent's anti-cheat stack. On top of that sits the standard UE4 hardening: stripped binaries, encrypted .text segments, and 23 obfuscated identifier-getters feeding a SHA-256 device fingerprint called ugId.

I reach root via the Mali GPU CVE-2022-38181 exploit. After the exploit I have a busybox telnetd listening on :4444 with uid=0 and u:r:kernel:s0. From there I insmod a small kernel module, kmem.ko, that exposes a single misc device, /dev/kmem_rw. The character device takes a 40-byte command struct and walks the target process's address space using the kernel's own access_process_vm().

Bootstrap diagram

A root shell with process_vm_readv would do the read, but it leaves three problems:

  1. process_vm_readv is a syscall. ACE can hook it, or read /proc/self/status for foreign tracers. The kernel module is invisible from userspace.
  2. The same module gives us kprobes (we trace ACE's own syscalls with those), DRM master elevation (for the overlay), and binder-reply rewriting (for ID spoofing) from one primitive.
  3. vfs_read on /proc/<pid>/mem needs PTRACE_ATTACH first, and ACE notices.

The IDA offsets we trust

Two values inside libUE4.so survive every reboot and every ASLR shuffle, because they live in .bss:

GUObjectArray:     IDA 0xe640ab8
FName hash table:  IDA 0xe6466f0   (20 buckets, separate-chained)

To locate GUObjectArray in IDA, find FUObjectArray::AllocateUObjectIndex and follow its single static reference. To locate the hash table, chase FName::Init's callees: the function hashes the string and stores the head pointer of the matching bucket. Both addresses stay put within an APK version. At runtime we add libUE4_base + IDA_offset.

Everything else in this writeup is bootstrapped from those two anchors.

The pointer chain that nearly broke me

The first surprise is that GUObjectArray + 0x10 is not the items array. It is a pointer to a second struct that itself contains the items array pointer.

GUObjectArray (FUObjectArray):
  +0x00  ObjFirstGCIndex
  +0x04  ObjLastNonGCIndex
  +0x10  ObjObjects (pointer → metadata struct)

  metadata struct:
    +0x00  Items*   (the flat FUObjectItem array)
    +0x08  MaxElements
    +0x0C  NumElements

I spent two sessions reading the wrong thing because of that extra hop. The data at GUObjectArray+0x10 looked like 8-byte-aligned pointers all the way down, which made me think UObject had no scalar fields. Then I realized I was reading the gNames chunk pointer array, where every slot is in fact a pointer.

The other landmine is the stride. FUObjectItem is 24 bytes (0x18), not 16. With a 16-byte stride every other "item" was the Flags field of the previous one, and the PUBG-internal indices aligned well enough to fool me for an hour. The fix:

FUObjectItem:
  +0x00  UObject*  Object
  +0x08  int32     Flags
  +0x0C  int32     ClusterRootIndex
  +0x10  int32     SerialNumber
  +0x14  int32     pad
  ----   stride = 0x18

Verifiable: items[1].Object.InternalIndex should equal 1.

Pointer chain sketch

Resolving names

Class names and object names in UE4 are FNames. An FName is a (ComparisonIndex, Number) pair where the comparison index points into a global string table. PUBG uses the old name table format (TNameEntryArray), not the modern FNamePool. The PUBG game profile in AndUEDumper confirms this:

bool IsUsingFNamePool() const override { return false; }

The table is a chunked indirect array. gNames points to ~36 chunks; each chunk is a 128 KiB block holding 16384 FNameEntry* pointers. To resolve a ComparisonIndex you do:

chunk_idx = ci // 16384
within    = ci % 16384
entry_ptr = read_ptr(gNames + chunk_idx*8)
entry_ptr = read_ptr(entry_ptr + within*8)
name      = read_cstring(entry_ptr + 0x0C)

gNames lives on the heap, so its address is fresh every launch. The bootstrap chain looks worse than it is:

1. FName hash table + bucket[0] →  "None" FNameEntry*
2. scan all rw- memory for that pointer  →  one hit: chunk0
3. scan all rw- memory for chunk0's pointer  →  one hit: gNames
4. read 36 × 8 bytes at gNames  →  full chunk array

The hash table puts "None" in bucket 0 because the hash degenerates on the short leading byte. Convenient. Once chunk0 is in hand, chunk0[0] is "None", chunk0[1] is "ByteProperty", chunk0[2] is "IntProperty". If those three round-trip, the chain is correct.

// resolve_name in ue4_radar.c
unsigned long entry_ptr = kmem_read_ptr(g_pid,
    g_chunks[chunk_idx] + (unsigned long)within * 8);
kmem_read(g_pid, entry_ptr + FNAMEENTRY_NAME, out, outlen - 1);

There is a faster Frida bootstrap that uses Process.enumerateRanges('rw-') and looks for ranges whose first pointer matches the "None" entry. Two notes on Frida here: a Memory.scanSync call gets the agent killed within a second by ACE's scanner, and the agent itself dies after about thirty seconds. Use Frida as a one-shot bootstrapper, then detach and read the rest through /dev/kmem_rw.

Name table chunk layout

Finding the actors

With ITEMS_ARRAY, NUM_ELEMENTS, and CHUNKS[] filled in, we can ask the live process what objects it has. The Frida helper enumerates GUObjectArray, looks at each object's ClassPrivate->NamePrivate, and filters by class name. Three queries get us into the world:

findbyclassname("World", 10)
//   Default__World
//   Baltic_Main      ← active world (Erangel)

findbyclassname("Level", 10)
//   PersistentLevel  ← we want this one

findinstances("STExtraCharacter", 200)
// returns every player/bot/pet currently network-relevant

UWorld + 0x30 is the PersistentLevel*. That part matches the public UE4 4.18 layout. ULevel.Actors is at +0xA0 in this APK and was at +0x90 in the previous one. Verify by reading the first actor and checking it resolves to "WorldSettings".

UWorld
  +0x30  PersistentLevel*    →  ULevel
                                  +0xA0  Actors TArray  (Data*, Count, Max)

Actors that matter for ESP inherit from STExtraCharacter. The hierarchy is deep: a player pawn is BP_PlayerCharacter_NewbieGame2_C → BP_PlayerPawn_C → STExtraPlayerCharacter → STExtraCharacter → ACharacter → APawn → AActor → UObject. To match every subclass, walk UStruct.SuperStruct at +0x30.

Position and health

For the Erangel build of PUBG, the data we want sits at fixed offsets from the actor base:

AActor + 0x128   ReplicatedMovement.Location   FVector  (3 × float, 12 bytes)
AActor + 0x208   RootComponent (CapsuleComp)*  pointer
AActor + 0xE60   Health                         float
AActor + 0xE64   MaxHealth                      float
AActor + 0x960   CachedPlayerName               FString  (UTF-16)

The position fields are not encrypted, not XOR'd, not behind a getter. They are plain little-endian IEEE 754. Reading them from the kernel module:

float pos[3], hp[2];
kmem_read(pid, actor + 0x128, pos, 12);
kmem_read(pid, actor + 0xE60, hp,  8);
printf("X=%.1f Y=%.1f Z=%.1f  HP=%.1f/%.1f\n",
       pos[0], pos[1], pos[2], hp[0], hp[1]);

We found +0xE60 by scanning the actor's first 8 KiB for the float value 100.0 (0x42C80000), then waiting until a remote player took damage and re-reading. The address that drops to 58.2 is Health. The neighbouring 100.0 at +0xE64 is MaxHealth. Three other 100.0 hits at +0x182C, +0x1C00, +0x1C98 are probably BoostHealth and shield variants. Disambiguating those needs a player with active boosts.

Position came out the same way: scan a 2 KiB window of the actor for float triplets with magnitudes that fit Erangel's coordinate system (X, Y ∈ [0, 800000] cm, Z ∈ [-10000, 50000] cm), then move the player and watch which triplet updates.

In a 47-player BR match we read three remote players in real time, all in plaintext, all from the kernel, with zero bytes injected into the game.

There is a UE4 caveat: network relevancy. The server replicates characters only within ~500 m of the local player. Our wallhack range is the relevancy radius, not the map. Past that, the actors do not exist client-side.

Drawing the boxes

Reading positions is half the demo. The other half is putting them on the screen without showing up in a screenshot or in dumpsys SurfaceFlinger.

The standard route fails on launch. A transparent overlay window via WindowManager.LayoutParams.TYPE_APPLICATION_OVERLAY makes ACE enumerate Surfaces, find a foreign window over the game, and refuse to start. Drawing into the framebuffer with fb0 is gone too: Pixel 6 has no fbdev.

So we go to DRM/KMS directly. The Exynos display controller on the Pixel 6 exposes 6 hardware planes through /dev/dri/card0. The Hardware Composer (HWC) uses planes 0, 1, 2, and 4. Planes 3 and 5 are free. A free plane that the display hardware composites is invisible to anything asking SurfaceFlinger what it scans out. SurfaceFlinger does not own it.

   userspace                            display hw
   ────────                             ──────────
   SurfaceFlinger ── HWC layer 0 ──┐
                  ── HWC layer 1 ──┤
                                   ├── plane 0 ─┐
                                   ├── plane 1 ─┤
                                   ├── plane 2 ─┤  ── final scanout
                                   └── plane 4 ─┤
   our drm_overlay ── plane 3 ─────────────────┤

Claiming a plane needs DRM master, which only HWC holds. The kernel module installs a kprobe on drm_ioctl_kernel and strips the DRM_MASTER permission flag for our PID when it sees a SETPLANE from us. After that we issue DRM_IOCTL_MODE_SETPLANE ourselves.

The render loop is small: allocate a dumb buffer in ARGB8888 at the screen resolution, mmap it, draw boxes from the latest ESP data, flip. Around 20 Hz. The ESP data pipe is /data/local/tmp/esp_data.txt written by ue4_radar, read by drm_overlay.

Overlay rendering

Verification:

$ adb shell screencap /sdcard/grab.png
# grab.png shows the game with no boxes
# physical screen shows the game WITH boxes

Screenshots and screen recordings go through the SurfaceFlinger composition path; our plane skips that path. The overlay shows up on the panel but no frame capture API can see it.

What ACE catches

Two empirical results from the Pixel 6:

The kernel reads are invisible. I can run kmem_tool continuously, scan for new actors every second, dump positions and health for an entire match, and ACE never reacts. No bans, no disconnects, no am force-stop.

The visible overlay gets caught. A run with the boxes drawn on visible enemies results in a device ban about ten minutes later. The ban is on the device fingerprint (ugId), not the account: a fresh guest sign-in on the same device is also banned. The only thing different between a banned run and a clean run is whether the boxes were drawn on top of players. The memory access pattern was identical.

That points at one of these as the detection vector:

  1. DRM_IOCTL_MODE_GETPLANE enumeration finding an active plane that no Android process owns.
  2. HWC layer-state inspection from the system_server side.
  3. A GPU-level capture of the final scanout buffer (the one place where the overlay composites in).

I have not yet narrowed it down. A kprobe trace on ACE's DRM ioctls would settle it.

What this means for the thesis

The CEV-R3 (memory edit / read) row of the threat matrix now has an empirical PoC. A kernel-level reader pulls full plaintext player state out of PUBG Mobile, uses no userspace primitive that ACE can hook, and runs as long as the match lasts. The R3 capability requirement (root + bootloader unlock for the kmem.ko load) is met by the Mali exploit chain.

The stealth rendering side is half a result. The DRM hardware plane is invisible to every userspace screenshot path we tested, but ACE detects it through some other channel. One of two things is going on: the server-side rendering stack is more transparent than I thought, or ACE has a kernel-side accomplice (a vendor library calling DRM ioctls during its own scan loop). The thesis will document the gap: overlay invisibility from screencap does not mean render invisibility from ACE.

Files

  • kernel-cheat/kmem.c: the kernel module
  • kernel-cheat/kmem_tool.c: userspace CLI for the misc device
  • kernel-cheat/ue4_radar.c: the actor enumerator and ESP data writer
  • kernel-cheat/drm_overlay.c: the hardware-plane renderer
  • kernel-cheat/libkmem.h: single-header userspace interface to /dev/kmem_rw

The full source tree lives in the thesis repository (private until publication).

Table of Contents