PUBG Mobile (com.tencent.ig) ships with ACE/MTP, Tencent's in-house anti-cheat stack. ACE is partly a userspace watchdog inside the game process and partly a bundle of native libraries (libanogs.so, libanort.so, libTDataMaster.so) that handle identifier collection, integrity checks, and signal-based anti-debug. The SDK is thorough enough that the usual Android approaches get you kicked in seconds: frida-server on port 27042, TYPE_APPLICATION_OVERLAY windows, ptrace from a root shell.
What it doesn't do is anything about kernel modules. I wrote a small one, kmem.ko, exposed it as /dev/kmem_rw, and read the UE4 object graph straight out of the game process using access_process_vm. No syscalls fire from a foreign tracer. No code runs inside com.tencent.ig. ACE never notices.
kmem.ko
Root is the Mali shrinker CVE-2022-38181, which gives me a uid=0, u:r:kernel:s0 busybox telnetd on port 4444 until reboot. From there insmod kmem.ko registers a misc device. The character device speaks a single 40-byte command struct:
;
// READ path (abridged)
static long
Going through a kernel module instead of process_vm_readv from a root shell buys three things. process_vm_readv is a syscall I'd have to issue from a foreign process, which ACE can hook or pick out of /proc/<gamepid>/status (TracerPid, for example, gets inspected regularly). /proc/<pid>/mem is worse: opening it is fine, but reading requires PTRACE_ATTACH on 5.x kernels. And the same module is the natural place for everything else I need later: kprobes on ACE's syscalls, a kretprobe on binder_ioctl for ID spoofing, and the DRM master trick described further down.
Two IDA anchors
UE4 games leak two useful globals in .bss: the object array and the FName hash table. Both sit at fixed offsets inside libUE4.so and they do not move between launches, only between APK versions. For this build:
GUObjectArray: libUE4.so + 0xe640ab8
FName hash table: libUE4.so + 0xe6466f0 (20 buckets, separate-chained)
GUObjectArray is the canonical dumper landmark. Every open-source UE4 SDK generator finds it the same way: locate FUObjectArray::AllocateUObjectIndex and read its single static data reference. It's the only static-addressed global that both writes into the array and references the array directly, so even stripped it's unambiguous.
The hash table needs slightly more work. FName::Init hashes the string, masks against the bucket count, reads GNameHashArray[index] as the head pointer, and walks HashNext looking for a match. The pointer load off a static symbol is the bucket table. I grepped callers of the public FNameHelper::NameToDisplayString style functions until I hit it.
FUObjectArray
The first thing that cost me a session is that GUObjectArray + 0x10 is not the items array. It's a pointer to a second struct that holds the items pointer. Public Epic sources spell this out. FUObjectArray starts with four int32 GC counters and then embeds a TUObjectArray:
FUObjectArray (at IDA 0xe640ab8):
+0x00 int32 ObjFirstGCIndex
+0x04 int32 ObjLastNonGCIndex
+0x08 int32 MaxObjectsNotConsideredByGC
+0x0C int32 OpenForDisregardForGC
+0x10 TUObjectArray ObjObjects ← WHERE THE ITEMS ACTUALLY LIVE
But TUObjectArray in UE4 4.18 is a TArray-like wrapper, not the flat array directly:
TUObjectArray at GUObjectArray+0x10:
(deref once to reach the heap struct)
+0x00 FUObjectItem* Items ← FLAT ARRAY, FINALLY
+0x08 int32 MaxElements
+0x0C int32 NumElements
Two pointer hops, not one. The first time I hit this I was reading what I thought was the items array but was in fact the gNames chunk pointer array: an entire memory region of aligned pointers with nothing else in it, which looked plausible enough that I spent a session convincing myself that UE4 had made UObject into a pointer-soup type. It had not.
The other trap is FUObjectItem's stride. AndUEDumper's UEOffsets.cpp lists it as 24 bytes (0x18):
FUObjectItem:
+0x00 UObject* Object
+0x08 int32 Flags // EInternalObjectFlags
+0x0C int32 ClusterRootIndex
+0x10 int32 SerialNumber
+0x14 int32 <pad> // ALIGN8(20) = 24
In a 16-byte stride world every second entry I read decoded as a valid-looking UObject* because Flags for a live object is a small bitmask that happens to land in the low bits of a dangling heap address. It fooled me for an hour before I cross-checked items[1].Object->InternalIndex == 1. The correct stride lines that up; the wrong one doesn't. This is a sanity check worth burning into any UE4 bootstrap routine.
Names
UE4 has two FName table implementations. The post-4.23 one (FNamePool) is a chunked inline allocator packed into .bss. The pre-4.23 one (TNameEntryArray) is a chunked pointer array with entries on the heap. Which one a game uses is not guessable from the engine branding. AndUEDumper ships per-game profiles; for PUBG the profile is explicit:
// tools/AndUEDumper/src/UE/UEGameProfiles/PUBG.hpp
bool
The first time I dumped PUBG I didn't check this, assumed FNamePool, and spent the afternoon pattern-scanning .bss for a header that was never going to be there. Lesson: read the game profile before you read memory.
The old table layout is two levels of indirection:
gNames → array of CHUNK POINTERS (~36 slots, grows at runtime)
chunk[N] → 16384 FNameEntry* slots (128 KiB)
FNameEntry → { HashNext*(8), Index(4), Name(variable-length C string) }
To resolve a ComparisonIndex:
= // 16384
= % 16384
=
=
=
gNames lives on the heap, so it moves every launch and you have to find it fresh each time. The trick is that every UE4 instance seeds the table with "None" at ComparisonIndex=0, and "None" (hash = 0) lands in bucket 0 of the hash array because the hash function degenerates on a short common string. So:
1. read FName hash + bucket[0] → "None" FNameEntry* (on the heap)
2. scan process rw- memory for that ptr → ONE hit: chunk0 (chunk0[0] = None)
3. scan process rw- memory for chunk0 → ONE hit: gNames (gNames[0] = chunk0)
4. read 36 × 8 bytes at gNames → full chunk pointer table
Each scan terminates on a unique hit because the pointer you're looking for is only held in one array. Verification is cheap: chunk0[1] is "ByteProperty", chunk0[2] is "IntProperty", chunk0[3] is "Int8Property". If those round-trip the table is wired up correctly.
The kmem-based scan works but is slow (about 6 GB of address space to walk) and has one nasty side-effect: reading huge contiguous regions occasionally trips ACE's "scan pattern" heuristic and kills the game. The safer bootstrap is a one-shot Frida script that uses Process.enumerateRanges('rw-') and only reads the first pointer of each 128 KiB-ish heap range. That finds chunk0 in under a second. Detach immediately after; ACE's Frida scanner catches anything that lingers past ~30 seconds, and Memory.scanSync gets the agent killed within the first second regardless.
Finding the world
Once the name table works, every UObject in memory becomes identifiable. The three queries that matter are trivial iterations over GUObjectArray with a name filter:
// RUN UNDER FRIDA, ONE-SHOT
"World" // Default__World, Baltic_Main
"Level" // PersistentLevel
"STExtraCharacter" // every network-relevant player/bot/pet
"Default__<Class>" is the CDO (class default object), the template UE4 uses when instantiating the class. Skip it. "Baltic_Main" is Erangel. "STExtraCharacter" is PUBG's root character class; every player pawn ultimately inherits from it through a long Blueprint chain:
BP_PlayerCharacter_NewbieGame2_C
└── BP_PlayerPawn_C
└── STExtraPlayerCharacter
└── STExtraCharacter
└── ACharacter → APawn → AActor → UObject
To match all subclasses of STExtraCharacter you walk UStruct.SuperStruct at +0x30 (this is a UE4-wide invariant: UField::Next is 8 bytes after the UObject header, UStruct::SuperStruct is 8 after that) until either NULL or the class you're looking for.
The landmarks to get from UWorld down to the actor array are:
UWorld
+0x30 PersistentLevel (ULevel*)
ULevel
+0xA0 Actors (TArray<AActor*> = { Data*(8), Num(4), Max(4) })
The +0x30 for PersistentLevel has been stable since UE4 4.14 or so. The ULevel::Actors offset is less kind. In the previous APK I looked at it was +0x90. In this one it's +0xA0, most likely because somebody added a pointer field above it between versions. The cheap verification is to dereference Actors[0] and check the name resolves to "WorldSettings". That actor is index 0 in every ULevel, courtesy of ULevel::PostLoad.
Position and health
The offsets I want on each character are:
AActor + 0x128 ReplicatedMovement.Location FVector (3 × float)
AActor + 0xE60 Health float
AActor + 0xE64 MaxHealth float
AActor + 0x960 CachedPlayerName FString (UTF-16)
ReplicatedMovement is the FRepMovement struct every replicated AActor carries; its first field is FVector Location. The whole point of FRepMovement is that the server pushes it to every client that has the actor in its relevancy set, so reading it gets me the authoritative position the game engine is about to render at. I didn't reverse any game code to locate it; I scanned the actor's first 2 KiB for float triplets with Erangel-plausible magnitudes:
X, Y ∈ [0, 800000] cm (Erangel is 8 km × 8 km)
Z ∈ [-10000, 50000] cm (ocean floor to hilltop)
Three triplets show up inside each actor: one at +0x128, one at +0x1C8 inside the root CapsuleComponent, and one further in that I never identified. +0x128 tracks the server-replicated value the fastest when the character runs, so that's the one.
Health was easier. Every live actor's Health field is 100.0f at spawn. I scanned the actor's first 8 KiB for the exact float bit pattern 0x42C80000 and waited until a remote player took damage. Four offsets hit 100.0:
+0x0E60 100.0 ← THIS ONE DROPPED TO 58.2 ON DAMAGE
+0x0E64 100.0 ← STAYED AT 100.0. MAXHEALTH.
+0x182C 100.0 ← STAYED AT 100.0. BOOSTHEALTH?
+0x1C00 100.0 ← STAYED AT 100.0. SHIELD?
+0x1C98 100.0 ← STAYED AT 100.0.
Only +0xE60 correlated with visible damage. +0xE64 is the paired MaxHealth. The others need a player with active boosts or shields to disambiguate and I never cared enough.
None of these are encrypted. No XOR, no pointer obfuscation, no getter function. The two reads are:
float pos, hp;
;
;
That's the entire payload for an ESP. The only caveat is UE4's network relevancy: the server only replicates characters within roughly 500 m of the local player, and actors outside that radius don't exist client-side at all, so the wallhack range is the relevancy radius and not the map. A player sniping from 800 m away is literally not in my process memory.
Overlay surfaces on Android
Reading the positions is the easier half. Drawing boxes on the screen that survive a screenshot is not. Android deliberately makes this hard, and ACE walks the rest of the way.
The obvious routes:
- A transparent window via
WindowManager.LayoutParams.TYPE_APPLICATION_OVERLAY. ACE enumeratesSurfaceFlingerlayers at launch and refuses to start the game if anything foreign is covering it. Dead on arrival. - Drawing into
/dev/graphics/fb0. There is nofb0on a Pixel 6. DRM/KMS replacedfbdevon most Google devices two generations ago. - Compositing into SurfaceFlinger as a privileged system layer. Requires platform signature or ROM modification; not in scope when the Mali exploit only gets you root, not a platform key.
What does work is drawing directly to a DRM/KMS hardware plane the compositor isn't using.
The Exynos display controller on the Pixel 6 exposes six hardware planes per CRTC. The stock Hardware Composer (HWC) uses planes 0, 1, 2, and 4 for the usual status-bar / app / navigation composition. Planes 3 and 5 sit idle. A plane that the display hardware composites is invisible to anything asking SurfaceFlinger what layers it scans out, because SurfaceFlinger does not own it. The display controller does.
SurfaceFlinger → HWC → plane 0
plane 1 ┐
plane 2 │ display
plane 4 ├─► scanout
│
drm_overlay → plane 3 ─────────────────────────────┘
plane 5 (free)
screencap, MediaProjection, dumpsys SurfaceFlinger, and anything else that goes through the SurfaceFlinger composition path reads the SF-composited framebuffer before the display hardware merges in the external planes. So those paths see the game without boxes. The panel sees the game with boxes. That asymmetry is the whole point.
Claiming a plane
The Linux DRM protocol serializes mode-setting and plane operations under a per-fd "master" state. Only the current DRM master (HWC, in this case) can call DRM_IOCTL_MODE_SETPLANE. Everyone else gets -EACCES from drm_master_check inside drm_ioctl_kernel.
The clean way to take master is DRM_IOCTL_SET_MASTER, but that revokes it from HWC and breaks the display. The ugly way is to patch drm_ioctl_kernel in-flight. kmem.ko installs a kprobe on that function that looks at current->tgid and, if it matches the PID that requested elevation, clears the DRM_MASTER requirement for the specific IOCTL being issued:
// SIMPLIFIED. ACTUAL CODE HANDLES THE `struct drm_ioctl_desc` FLAGS FIELD.
static int
From drm_overlay's perspective SETPLANE now succeeds. HWC never notices and the display keeps composing. The rest of the overlay is unremarkable: allocate a 1080×2400 dumb buffer in DRM_FORMAT_ARGB8888, mmap it, draw rounded-corner rectangles for each actor with an alpha gradient for the health bar, SETPLANE at ~20 Hz off a pipe fed by ue4_radar.
Verification is a pair of terminal commands:
# WHAT THE ANDROID FRAMEWORK SEES
adb shell screencap -p /sdcard/grab.png
# grab.png CONTAINS THE GAME AND NO OVERLAY
# WHAT THE PANEL SHOWS
# (PHYSICAL CAMERA. NO SCREEN RECORDING PATH WILL CAPTURE THIS.)

What ACE catches
Two empirical observations.
Kernel reads don't register. ue4_radar runs kmem_read calls at 20 Hz for an entire match: thousands of reads per second across the items array, gNames chunks, and every visible actor's health/position/name. I've never been kicked or banned from a run that only read memory. The game's self-inspection never touches kernel modules it didn't load, and the read path leaves no artifact in /proc/<gamepid>/* that a userspace scanner can see.
The overlay gets caught inside ten minutes. Every single run where the overlay is drawn on visible enemies ends in a device ban. The ban is not on the Tencent account, it's on the device: ugId in device_id.xml is a SHA-256 over 23 device-identity components, and signing in as a fresh guest on the same device produces an instantly banned session. I confirmed this three times.
The interesting part is that runs with the overlay drawing a static test pattern (boxes at fixed screen coordinates, not on players) took measurably longer to ban. That rules out pure DRM plane enumeration as the primary detection, because the plane is as active in both cases. Three remaining hypotheses, in rough order of plausibility:
DRM_IOCTL_MODE_GETPLANEenumeration from inside the process, filtered on planes with active framebuffers not owned by SurfaceFlinger. Would show a single unexpected plane in both cases, so the time difference argues against this.- HWC layer-state inspection via the vendor HAL. ACE talks to
/dev/socket/hwservicemanagerduring startup; a vendor interface could expose the current plane assignments. Ditto: static vs. dynamic shouldn't differ. - A GPU-side scanout-buffer grab, either by opening a second DRM client and reading back the composed framebuffer, or by going through a Gralloc/Mali vendor path that returns the post-composition image. This is the only hypothesis consistent with the timing: a classifier that flags "overlay on top of a player-shaped sprite" would naturally take longer to trigger when there are no players under the overlay.
I have not yet confirmed which one. A kprobe trace on drm_ioctl_kernel, drm_ioctl, and the Mali kbase_ioctl entry point filtered on the game's PID would settle it in a single match. That's the next thing on my list.
Files
kernel-cheat/kmem.c: the kernel modulekernel-cheat/kmem_tool.c: userspace CLI for/dev/kmem_rwkernel-cheat/ue4_radar.c: actor enumeration + ESP feedkernel-cheat/drm_overlay.c: the hardware-plane rendererkernel-cheat/libkmem.h: single-header interface to the misc device