Building Kali NetHunter for the POCO M4 5G Without Guessing
What it took to bring NetHunter to Xiaomi's MediaTek-based POCO M4 5G—from an unsafe first kernel build to working HID and external Wi-Fi.
- Device
- POCO M4 5G (light / 22041219PI)
- SoC
- MediaTek MT6833, ARM64
- ROM
- HyperOS Android 14 / U, V816.0.4.0.ULSINXM
- Kernel
- Linux 4.19.191
- Toolchain
- Android clang r383902 / LLD 11.0.1
- Status
- Validated v0.1.0 reference build
I went into this project with a simple goal: turn my POCO M4 5G into a useful Kali NetHunter device. On paper, that sounded like a kernel configuration job enable the right options, compile, boot, done.
It was not that simple.
The first kernel that compiled cleanly was not safe to boot. HID support worked,
but not through the device nodes I expected. The external Wi-Fi adapter appeared
as wlan2, then crashed the phone in two different CFI paths. Even ADB and HID,
which both worked separately, found creative ways to break when I put them into
the same USB gadget.
By the end, I had a public v0.1.0 research reference for the Indian POCO M4
5G (light) on HyperOS Android 14: an ABI-clean kernel baseline, working
keyboard HID, an accepted ADB + HID composite, USB serial support, and an
archived RTL88XXAU build that passed monitor-mode and passive radiotap capture
tests.
Some pieces are still unfinished. Mouse HID needs more work, Kali does not yet have native Bluetooth, and I have not reconstructed the exact final RTL88XXAU compatibility changes into a clean public patch. I would rather say that plainly than turn a useful research build into a misleading success story.
If you only want the story, read straight through. If you are here because you have the same phone and want to repeat the work, the practical route is in How to follow this project. It includes the actual source commit, configuration checkpoints, build wrapper, ABI gate, and the points where this public archive deliberately stops.
What changed, in plain English
There are a few different systems involved here, and it is easy to blur them together. This is the short version of what I changed and where it lives:
| Layer | What changed | Why |
|---|---|---|
| Xiaomi kernel baseline | Rebuilt from the Android-U source/config and checked against the ROM’s module ABI | Establish a kernel that could safely host the existing vendor modules |
| Kernel USB gadget code | Enabled ConfigFS functions and fixed Xiaomi’s HID descriptor overwrite | Let NetHunter create its keyboard and mouse functions correctly |
| NetHunter userspace | Patched USB Arsenal state handling and resolved HID nodes dynamically | Preserve ADB and stop assuming that keyboard always means /dev/hidg0 |
| USB serial | Enabled CH341, CP210x, FTDI, and PL2303 drivers | Support common serial adapters from Kali |
| External Wi-Fi | Integrated RTL88XXAU and corrected CFI-sensitive driver paths in the archived build | Provide monitor mode and passive capture through a USB adapter |
| Boot animation | Added a separate Magisk overlay | Change the animation without writing to the read-only product partition |
The kernel, Android USB state, NetHunter scripts, and Magisk module are separate layers. A fix in one does not automatically fix the others. Keeping those boundaries clear saved me from rebuilding the kernel for what turned out to be a userspace mapping problem.
Start with the phone that actually exists
Before touching the kernel, I recorded the device as it was running:
| Component | Verified target |
|---|---|
| Device | POCO M4 5G, light, model 22041219PI |
| SoC | MediaTek MT6833, ARM64 |
| ROM | HyperOS Android 14 / U, V816.0.4.0.ULSINXM |
| Kernel | Linux 4.19.191 |
| Compiler family | Android clang r383902, clang/LLD 11.0.1 |
The stock kernel source came from Xiaomi’s official
light-u-oss
branch at commit
a40ceab8943703f33e935cb0b86d0f69aa2044dd.
I pinned the commit as well as the branch because branches move. Months later,
I wanted to be able to answer “which Xiaomi source did this actually come
from?” with something better than a vague repository link.
Xiaomi’s published tree is not a perfect reconstruction of what shipped on the
phone. It expects the MIHW tree at
vendor/xiaomi/kernel_modules/mihw/, but that source is absent. Disabling the
unavailable pieces got the build moving again. It also created the first trap:
the compiler finished successfully, which felt like progress, but said nothing
about whether the phone’s existing vendor modules could safely use that kernel.
The first successful build was not a safe build
Android vendor modules have to agree with the kernel’s exported symbol ABI.
This device uses CONFIG_MODVERSIONS, so imported kernel symbols carry CRCs.
Those CRCs describe the ABI a module was built against.
My early candidate looked convincing at first. The release string was right,
the module vermagic looked right, and there was a fresh kernel image sitting
in the output directory. Then I compared every imported module symbol against
the kernel’s exported symbol table.
| ABI audit stage | Kernel matches | CRC mismatches | External or unlisted imports |
|---|---|---|---|
| Early candidate | 986 | 8 | 79 |
| Correct HyperOS U baseline | 994 | 0 | 79 |
Eight CRCs did not match. The list included core symbols such as
wake_up_process and set_user_nice, plus vendor-facing connectivity and MMC
exports. I quarantined that build instead of booting it “just to see.” At that
point, a failed boot would not have taught me anything I did not already know:
the kernel and its modules disagreed about their ABI.
After moving to the correct Android-U baseline, the same audit found 994 matching kernel imports and zero CRC mismatches. The other 79 imports were external or absent from the kernel’s own symbol table, so I kept them in a separate bucket instead of quietly counting them as successes.
Build in layers, not in one heroic patch
With a baseline I could trust, I resisted the urge to throw every NetHunter option into one giant build. I split the work into four lineages:
- an ABI-clean baseline;
- USB gadget and HID support;
- USB serial support;
- an external Wi-Fi reference build.
I saved the configuration and Module.symvers at each stage. It was a little
extra housekeeping that paid for itself repeatedly. When something broke, I
could compare it with the last known-good layer instead of staring at one huge
patch and guessing.
The test repacks kept the rooted stock ramdisk and stock DTB. That constrained the investigation to the intended kernel changes and avoided quietly mixing device-tree or userspace experiments into the same result.
HID worked only after following ConfigFS all the way down
HID looked like it should be easier. The kernel had the gadget functions, the phone had USB-C, and NetHunter already knew how to drive a keyboard gadget. In practice, Xiaomi’s Android userspace and the live ConfigFS state had the final say in what the connected computer actually saw.
The first problem was Xiaomi’s f_hid behavior: the report descriptor could be
overwritten instead of preserved. Fixing that made the function usable, but my
test payloads still went nowhere when I wrote to the device nodes I expected.
The active gadget was g1, bound to the musb-hdrc UDC. The clue was that a
ConfigFS function name did not reliably predict its character-device number.
For example, hid.0 and hid.1 could appear as /dev/hidg1 and /dev/hidg2,
not the tempting /dev/hidg0 and /dev/hidg1 pair.
Once I stopped assuming and read the live mapping, the path became clear: discover the node, verify the report length, and use what the running gadget actually created.
The payoff was wonderfully ordinary: the phone typed a harmless hello world
on the host. Keyboard HID was real. Mouse HID is still only partially working,
so it stays on the unfinished list.
USB Arsenal had to cooperate with Android
That small keyboard win immediately led to the next problem. A standalone HID function was one thing; asking NetHunter’s USB Arsenal to keep ADB alive beside it was another. Android already manages USB through properties, init services, FunctionFS, and an ADB daemon that becomes ready on its own schedule.
Several failures came from stale gadget state rather than missing kernel support:
- old
ORI_FUNCSstate was reused; - stale ConfigFS links survived a transition;
ffs.adbcould be linked twice;- Android init raced with FunctionFS and the application;
- a composite could look correct in ConfigFS while ADB was not yet ready.
I eventually stopped treating each attempt as a clean slate, because it was not. The working approach inspected the live gadget, removed only stale links, avoided duplicate FunctionFS functions, and respected Android’s property-driven ADB lifecycle. The accepted result was the application-button ADB + HID composite. I am not claiming that every boot-time persistence path is solved.
One small but important caveat: the accepted script is in the public record and the composite was functionally tested, but I do not have a recorded, hash-matched functional rerun after reboot. I am not going to retroactively invent one. Good notes should tell the next person what happened, not what would make the neatest timeline.
USB serial support was the straightforward part
After all of that, USB serial was pleasantly boring. I added support for the common adapter families I wanted:
- CH341;
- CP210x;
- FTDI;
- PL2303.
Then I rebuilt, saved the new config and symbols, and ran the ABI audit again. Even boring changes have to pass the same gate.
External Wi-Fi: enumeration is not stability
The external Wi-Fi target was a TP-Link adapter with USB ID 2357:0120, using
the RTL88XXAU driver. Work began from NetHunter’s Linux 4.19
rtl88xxau-5.6.4.2 patch family.
The early result looked great: the adapter enumerated and appeared as wlan2.
For a moment, it looked as if the difficult part was over. Then the phone
rebooted.
One build hit a CFI failure at rtw_xmit_entry. A later revision got farther,
only to fail at usb_recv_tasklet and __cfi_check_fail. Without persistent
logs, either crash could have looked like a flaky cable, a power problem, or
just another unexplained Android reboot.
pstore/ramoops broke that guessing loop. The crash evidence survived the reboot, pointed to the actual call sites, and showed that USB detection was not the problem. The driver and the kernel’s Control Flow Integrity checks disagreed.
The final archived reference build passed these tests:
- adapter enumeration as
wlan2; - monitor-mode transition;
- passive radiotap capture;
- repeated short-session use on the tested device.
I am not making a public injection claim, and suspend/resume testing is still limited. There is another uncomfortable detail: I have the working archived build, but I have not yet reconstructed the precise compatibility delta between it and the CFI-crashing revisions as a clean source patch. That makes it a useful, tested reference—not yet a reproducible Wi-Fi package.
Why Android Bluetooth works but Kali has no hci0
Bluetooth produced one of the most confusing results in the whole project: it
worked perfectly well in Android, while Kali could not see an hci0 device at
all. Both observations were correct.
The stock Android stack talks to the MediaTek connectivity subsystem through
the vendor CONNAC/WMT path. The device evidence includes the MT6631 family,
bt_drv_connac1x, wmt_drv, 1100c000.btif, /dev/stpbt, and MediaTek’s
Bluetooth HAL.
The shipping kernel, however, does not expose the normal Linux Bluetooth stack
expected by BlueZ: CONFIG_BT and RFKill support are absent, and there is no
native hci0 or AF_BLUETOOTH path for Kali.
Flipping CONFIG_BT would not magically turn MediaTek’s vendor transport into
a standard Linux HCI device. That needs actual integration across the kernel,
vendor driver, and userspace. For now, native Kali Bluetooth is not
implemented, and Android Bluetooth is left alone and working.
Systemless finishing work
Not every finishing touch needed a kernel rebuild. I wanted the NetHunter boot
animation too, but /product is read-only on this dynamic-partition layout. A
small Magisk module overlaid the relevant paths without touching the real
partition. After correcting the frames to the phone’s 1080×2408 display, it
survived a normal reboot.
It was the right tool for the job: reversible, systemless, and nowhere near a critical partition for what was ultimately a cosmetic change.
How to follow this project
This is the route I would use if I were starting again. It is a build and validation guide, not a flashing recipe. Android boot images contain device-specific and often proprietary pieces, so the public project does not ship one and this article does not tell you to flash an unverified image.
1. Confirm that you actually have the same target
Do not begin with the box label or a marketplace listing. Record what the running phone reports:
adb shell getprop ro.product.device
adb shell getprop ro.product.model
adb shell getprop ro.build.fingerprint
adb shell uname -a
adb shell 'zcat /proc/config.gz 2>/dev/null | grep CONFIG_MODVERSIONS'
For this project, the important anchors are light, model 22041219PI,
HyperOS Android U build V816.0.4.0.ULSINXM, and Linux 4.19.191. Stop if the
codename, ROM generation, kernel family, or module set differs. The method may
still be useful, but the saved configs and ABI result no longer prove anything
about your phone.
Before any boot testing, know the active slot, confirm that you can reach the bootloader independently of Android, and keep verified stock and rooted boot backups somewhere other than the phone. Record their SHA-256 values. The project’s recovery checklist is required reading, not an appendix for after something breaks.
2. Check out the two pinned source trees
Use the frozen public release for the project record and the exact Xiaomi commit used by the build:
git clone https://github.com/N3tm4t3/poco-m4-5g-nethunter.git
git -C poco-m4-5g-nethunter checkout v0.1.0
git clone https://github.com/MiCode/Xiaomi_Kernel_OpenSource.git xiaomi-light-kernel
git -C xiaomi-light-kernel checkout a40ceab8943703f33e935cb0b86d0f69aa2044dd
The project repository is small because it does not vendor Xiaomi’s full tree, the Android toolchain, proprietary modules, firmware, or boot images.
3. Recreate the compiler environment
The accepted build used Android clang r383902 with clang/LLD 11.0.1, plus
GNU AArch64 and ARM compatibility cross-compilers. You will also need the usual
Linux kernel build dependencies: GNU make, bc, bison, flex, Python, Git,
and the OpenSSL and ELF development headers.
The exact clang bundle is not included. Obtain it from a source you are allowed
to use and verify the identity instead of substituting whatever clang happens
to be installed globally:
/absolute/path/to/clang-r383902/bin/clang --version
/absolute/path/to/clang-r383902/bin/ld.lld --version
aarch64-linux-gnu-gcc --version
arm-linux-gnueabi-gcc --version
A newer compiler is not automatically a better compiler for reproducing an Android vendor kernel. Changing source, config, and toolchain at the same time makes any later difference much harder to explain.
4. Build the baseline first
Start with configs/baseline/.config, not the Wi-Fi checkpoint. The supplied
wrapper insists on absolute paths and writes to a separate output directory:
cd poco-m4-5g-nethunter
export KERNEL_SRC=/absolute/path/to/xiaomi-light-kernel
export TOOLCHAIN=/absolute/path/to/clang-r383902
export OUT=/absolute/path/to/build-output/baseline
export CONFIG="$PWD/configs/baseline/.config"
scripts/build/build-kernel.sh
The wrapper copies the selected config, runs olddefconfig, then builds the
kernel image, modules, and DTBs. It prints hashes for the resulting Image,
effective .config, and Module.symvers.
Do not ignore configuration drift. Compare what went in with what
olddefconfig produced:
diff -u "$CONFIG" "$OUT/.config"
Some normalization can be expected, but every unexplained change deserves a look. The missing Xiaomi MIHW source is part of the reason a simple stock build is not possible from the published tree.
5. Run the ABI gate before considering a boot
This step is what rejected my first apparently successful kernel. The checker
expects the new Module.symvers and a tab-separated import list derived from
the vendor modules on your own ROM:
module-name.ko<TAB>symbol_name<TAB>0xCRC
Then run:
scripts/abi-check/check-symbol-crcs.sh \
"$OUT/Module.symvers" \
/absolute/path/to/module-imports.tsv
For my retained module set, the accepted baseline reported:
TOTAL_IMPORTS=1073
KERNEL_MATCHES=994
KERNEL_MISMATCHES=0
EXTERNAL_OR_UNLISTED=79
The public repository intentionally does not include Xiaomi’s proprietary
.ko files or the extracted import table, and it does not yet provide an
end-to-end extractor for that TSV. You must derive it from modules you lawfully
hold and audit the result. That is a real reproducibility gap, not a file to
download from an unofficial mirror.
If a symbol exists in both datasets with a different CRC, stop. Do not explain
it away with matching uname, vermagic, or a successful compile.
6. Add one feature layer at a time
The repository preserves four checkpoints:
| Checkpoint | Purpose | Extra work |
|---|---|---|
configs/baseline |
Stock-compatible Android-U baseline | Establish this first |
configs/usb-v1 |
ConfigFS gadget functions and HID | Apply the reviewed f_hid patch only if its context matches |
configs/usb-v2 |
Focused USB serial drivers | Rebuild and repeat the ABI audit |
configs/wifi-stable |
Archived Wi-Fi configuration | Requires the cited NetHunter RTL driver and a final compatibility delta not fully published here |
For each layer: use a new output directory, preserve .config and
Module.symvers, rerun the symbol check, and test the baseline phone functions
before testing the new feature. That ordering makes it much easier to tell
whether a failure came from the kernel, Android userspace, or the new device.
The Wi-Fi checkpoint needs special caution. The starting NetHunter driver patch
is identified in the
RTL88XXAU source note,
but the minimal final CFI/compatibility delta was not retained separately. A
fresh clone can reproduce the configuration and the earlier failure path; it
cannot honestly reproduce the exact accepted Wi-Fi build yet.
7. Treat repacking and device testing as a separate gate
My private test images used Android boot header v2. I replaced only the kernel payload while preserving the already rooted stock/Magisk ramdisk and stock DTB. Every repack was unpacked again and its component hashes and header fields were compared with the intended inputs.
I am intentionally not including a generic flashing command here. Slot layout, bootloader behavior, recovery access, and the correct stock components must be verified on the reader’s actual device. The public archive supplies neither a flashable image nor the proprietary material needed to construct one.
When a candidate does boot, test in this order:
- normal Android boot and lock/home screen;
- Magisk/root;
- internal Android Wi-Fi and Bluetooth;
- ADB and charging behavior;
- the one newly added kernel feature;
- suspend/resume where relevant;
- pstore/ramoops after any reboot or crash.
“The adapter appeared” is not an acceptance test. For the final external Wi-Fi
reference I checked driver binding, wlan2, monitor mode, passive radiotap
capture, ordinary Android connectivity, runtime behavior, and post-crash
evidence. I did not turn that into an injection claim.
8. Apply userspace patches only after reviewing their anchors
The USB Arsenal and DuckHunter changes are not generic installers. They were
written around the ConfigFS gadget g1, the musb-hdrc UDC, Android’s
FunctionFS ADB lifecycle, and NetHunter 2026.2-era scripts.
Before applying anything, compare the patch with the exact script installed on
your device. The
USB Arsenal provenance record
documents every retained script hash and distinguishes the authoritative final
application-button-tested version from tracing and rejected persistence stages.
The read-only
resolve-hid-node.sh
shows the safer pattern: derive the character device from ConfigFS instead of
assuming /dev/hidg0.
What v0.1.0 actually means
The public release freezes the following state:
| Area | v0.1.0 status |
|---|---|
| Kernel baseline | ABI-clean for the documented HyperOS U target |
| Keyboard HID | Working and harmlessly validated |
| Mouse HID | Partial; needs more testing |
| USB Arsenal | Application-button ADB + HID composite accepted |
| USB serial | CH341, CP210x, FTDI, and PL2303 included |
| External RTL88XXAU Wi-Fi | Archived reference build passed monitor mode and passive capture |
| Wi-Fi reproducibility | Exact final CFI/compatibility delta still to reconstruct |
| Native Kali Bluetooth | Not implemented; Android vendor path remains functional |
| Internal MediaTek Wi-Fi monitor mode | Not established |
It is not the flashiest status table, but it is one I can defend.
Where the project goes next
There is plenty left to explore when I return to the project:
- reconstruct the exact final RTL88XXAU CFI/compatibility delta;
- finish HID mouse validation;
- expand suspend/resume and long-session testing;
- investigate a genuine MediaTek transport path to native
hci0and BlueZ; - determine whether internal MediaTek Wi-Fi monitor mode is realistic;
- package the patch and build process more reproducibly.
That work belongs in a future release. I am leaving the v0.1.0 tag where it is: a snapshot of the initial public research, not a pointer that moves every time I learn something new.
The takeaway
Looking back, the most valuable result is not the boot logo or even the list of working features. It is the process I ended up using:
- identify the exact shipping target;
- pin the real upstream source and toolchain;
- compare symbol ABIs before booting;
- introduce one feature family at a time;
- keep known-good rollback points;
- capture persistent crash evidence;
- separate enumeration from functional validation;
- document negative results and provenance boundaries.
That process turned an unsupported MediaTek phone into a genuinely useful NetHunter research platform. It also stopped me from confusing “it compiled,” “it appeared once,” and “it is proven to work”—three very different milestones.
The complete source history and evidence are public in the
poco-m4-5g-nethunter
repository. The deeper procedures and architecture notes live in the
project documentation, and the
frozen initial research state is documented in the
v0.1.0 release.