Sixty-four——I pwned an HONOR Magic3 using DeepSeek-V4.1-Flash
After giving it some thought, I've decided to place this article in the "blah" section, as the non-technical content makes up the greater part of the piece.
- Device: Honor ELZ-AN00 · Android 11 · build ELZ-AN00 5.0.0.189(C00E183R6P1)
- Kernel: 5.4.86-qgki-g33a9ed8573e1 #1 SMP PREEMPT aarch64
- Config: user build, locked bootloader, CFI_CLANG + LTO_CLANG + THINLTO (non-GKI)
- Actor: uid 2000, CapEff=0, no root helper
junyu33@zjy-asus:/mnt/c/Users/junyu33/Desktop$ bash /home/junyu33/elz-phase2/notes/artifacts/root.sh -v "id"
[check] target-page sha256 = 3720fc0ef95b2c4d654c6a660b211ee00eafac2bb37d85f5697dd483673a19d4
[chain] starting the chain (uid 2000 -> uid 0), about 40 s
=== P1: first bounded kernel write on the phone (uid 2000, no root, no GDB) ===
[0] links: T=0xffffffc012aae588 (ashmem_misc+0x10) read_jt=0xffffffc01182ae6c write_jt=0xffffffc01182b88c open=0xffffffc0101bd7a0 rt_mutex_init_waiter=0xffffffc0103686b4 do_futex=0xffffffc0103b3f04
[S0-ID] uid=2000 euid=2000 gid=2000 CapEff=0x0 CapPrm=0x0 Seccomp=0 NoNewPrivs=0
[S0-ENV] uname=5.4.86-qgki-g33a9ed8573e1
[S0-ENV] getenforce=1 domain=u:r:shell:s0 boot_id=19831966-28f0-4e7a-b177-25b40f829564
...
[chain] ready (uid 0 pty on 127.0.0.1:31337, adb forward active)
--- chain summary (154 lines) ---
[S0-ID] uid=2000 euid=2000 gid=2000 CapEff=0x0 CapPrm=0x0 Seccomp=0 NoNewPrivs=0
[S0-ENV] uname=5.4.86-qgki-g33a9ed8573e1
[S0-ENV] getenforce=1 domain=u:r:shell:s0 boot_id=19831966-28f0-4e7a-b177-25b40f829564
...
[S0-SLIDE] d=0x190 samples=28163 candidates=2 best=0x2bc3a00000 votes=27395 second=135
[S0-SLIDE] SLIDE=0x2bc3a00000 runtime_text=0xffffffebd3a80000 runtime_etext=0xffffffebd5240000
[S0-SLIDE] kernel IPs in [_text,_etext)=150000/150000
...
[7B] KREAD *(V+0x10)=0xffffffebd522ae6c (==read_jt: 1) *(V+0x18)=0xffffffebd522b88c (==write_jt: 1)
...
[P1C-DAEMON] RESIDENT anchor_pt=/dev/pts/0 anchor_fd=3 window_pipe=held_by_parent listening=127.0.0.1:31337 uid=0 pid=7880
[P1C-DAEMON] each connection forks a session child with its OWN pty+shell; this process never exits
...
[CRIT] pos_uid0=1 pos_gid0=1 pos_caps=1 pos_mount0=1 neg1_mount=-1 neg2_uid=2000 init_sid=1870 count_win=1848 sid_written=1 page_ctl=0
[END] *(T) restored to 0xffffffebd5cba298 (== ashmem_fops+slide: 1)
[T] round segment = 24680 ms
--- end summary ---
[shell] id
id
:/data/local/tmp # id
uid=0(root) gid=0(root) groups=0(root),1004(input),1007(log),1011(adb),1015(sdcard_rw),1028(sdcard_r),3001(net_bt_admin),3002(net_bt),3003(inet),3006(net_bw_stats),3009(readproc),3011(uhid) context=u:r:shell:s0:c999
:/data/local/tmp #
Introduction
On July 9, 2026, my friend misane sent me a WeChat article about Nebula Security's disclosure of GhostLock, a Linux LPE that had remained hidden for 15 years and affected all distributions running kernel versions from 2.6.39 through 7.1 at the time. On July 15, Nebula demonstrated the entire process of using the vulnerability to root Android 17. Naturally, I was intrigued and immediately wanted to try it on my old Honor Magic 3. But I was busy with a paper submission, so I had to put the idea aside.
Two months later, on September 18, without being able to unlock the bootloader, I finally saw that # and uid=0 on the screen. SELinux restrictions meant there was very little I could actually do with it, but seeing them was already satisfying enough.
Of course, the price was rather painful (lol). I spent a whole week and more than 200 CNY, burning through over 3 billion tokens on deepseek-v4.1 flash for agent orchestration before finally stumbling my way to the goal like a blind man—after all, the last time I had touched kernel pwn was probably four years ago.
Early Exploration
Now that we know about this vulnerability, to pwn the phone sitting next to us, and to keep the process as smooth as possible while eliminating uncertainty, it's best to follow these steps:
- Confirm the phone's kernel version, the date of the latest Android security patch, and other relevant fields, to determine whether the device is affected. (Of course, this is obvious — otherwise this post wouldn't exist.)
- Find the open-source kernel source code corresponding to your phone's OS version. In theory, under the GPL, phone vendors are required to release it — but some domestic vendors may say one thing and do another, going weeks without replying to emails. Then flash the phone to that version.
- Based on the corresponding kernel source, plus some device tree files the vendor has published, construct a Linux simulation environment with a kernel config as identical as possible — including but not limited to the architecture, compiler version, etc. Of course, it can't be made completely identical. For example: when building TEE-related kernel components, the vendor obviously won't attach its signing private keys to the open-source files.
- Now you have a Linux simulation environment — say, QEMU. Start reproducing each step according to the official writeup, then gradually tear away the scaffolding: enable LTO+CFI, enable KASLR, then strip debug symbols, and finally get a rootshell even without relying on gdb.
- Finally, take the exploit script you've finished in QEMU and actually go blind-debugging on that phone. How long this takes is highly uncertain. With luck, one or two iterations might resolve all the bugs or discrepancies; otherwise, you could get stuck on the last step forever, never knowing which offset is off by a few bytes — or, even worse, some discrepancy could make the exploit theoretically impossible in the first place.
Preparing the Flashing Infrastructure
Our goal is to get a root shell, but not every software version happens to be exploitable—or has the corresponding source code available. Switching between system versions by flashing the device is therefore more or less unavoidable. Since flashing always carries some risk, the first thing to do is back up the existing data. The backup process itself is outside the scope of this article.
Once the backup is done, there is much less to worry about. For this particular ELZ-AN00 device, downgrade packages can be found on Xianyu for around 1 CNY, allowing the phone to be downgraded all the way from MagicOS 8.0 to MagicUI 5.0.
To reduce the risk of permanently bricking the device, another useful—though not strictly necessary—tool is a firehose programmer for Qualcomm 9008 mode. This gives us low-level read/write access to the phone's non-secure-world partitions. If something does go seriously wrong, previously backed-up partition images can be written back to recover the device.
As for the firehose programmer, I just tried searching for it again and realized that I can no longer find the site where I originally downloaded mine. Most of the other sites I found now require an account or payment. Fortunately, I still have a copy locally, so if you genuinely need it, feel free to email me.
Besides the flashing packages and the firehose programmer, another important ingredient is the official source code. This device is relatively fortunate in that two versions can be found on HONOR's global open-source page:
ELZ-AN00_MagicUI5.0_Code_Opensource— HONOR Magic3MagicOS8.0-ROM-Elizabeth_MagicOS8.0_Opensource— HONOR Magic3 Pro, HONOR Magic3, HONOR Magic3 Ultimate
These happen to correspond exactly to the two ends of the version range covered by the 1-CNY downgrade package. In my later work, I ran into obstacles during the final round of on-device debugging on MagicOS 8.0, and eventually succeeded in exploiting the device on MagicUI 5.0 instead.
On the hardware side, the downgrade package from Xianyu is intended for local flashing through recovery. We therefore need a USB flash drive with a Type-C connector; otherwise, a Type-C adapter is unavoidable.
Which model to use as an assistant?
As mentioned in the intro, the last time I played CTF pwn was four years ago. Back then, even grinding BUUCTF every day, I had only just learned those handful of "house of xxx" exploitation tricks, and on the kernel side I had barely figured out the general flow of ret2usr. But with the boost from AI, even though I can't (and don't have the time or energy to) verify every detail of the exploitation process, I can at least keep the big picture straight. Having decided to get LLM's hands dirty, the first question was which LLM to choose. The situation at the time (early August) was as follows:
- GPT: I was actively using it, had a Plus subscription, the 5-hour limit hadn't returned yet, and I had enough reset cards for trial and error. But doing exploit work requires Cyber verification, which could be bought on Xianyu for a few dozen yuan. However, I was still waiting for the paper rebuttal and didn't want my account banned, losing the rebuttal context — not worth the risk.
- Claude: Mythos/Fable 5 was definitely top-tier among LLMs at the time, and I had an account from 2023 that still hadn't been banned. But I was still afraid of getting banned, and CVP was more expensive (several hundred yuan), so I didn't consider it.
- Gemini: Not considering the American Doubao. After testing its intelligence a few months ago, I never used it again.
- Kimi: K3 had just come out not long before, ranked first among Chinese models on lmarena.ai, and had no Cyber censorship. Downsides: slightly expensive (but reimbursement channels were offered), my senior said it had few tokens, and there was a waitlist.
- GLM: A Chinese model recommended by my senior. But its pricing plan had recently changed — more expensive than Kimi, but more tokens. Slightly weaker than Kimi in capability.
- DeepSeek: Also not considered — at the time, v4-flash/pro ranked far below Kimi/GLM.
So I was torn between Kimi and GLM, but in the end I decided on Kimi, because I had some connections from high school at Moonshot AI. I planned to clearly explain my background and goals, and see if going through my network could get me a referral to skip the waitlist and get some testing access — as it turned out, I got referred to the OpenHarmony CTF instead, where they asked if I wanted to compete. Honestly, that felt worse than not being referred at all.
No way around it, I decided to reach out to that Moonshot AI "relevant person" myself. I did three things:
- Tried DMing them on X — they don't allow DMs from strangers
- Emailed them directly — no response
- Even submitted a PR to one of their fairly old GitHub projects — still no response
That route was clearly dead too. So I went back to my senior and asked if he'd ever used Kimi. He said he had an account, but couldn't lend it out due to company privacy policies. However, he pointed me to a path: buying a waitlist-approved account on Xianyu. The result was simple — solved for a bit over ninety yuan.
In hindsight, not choosing GLM back then was such a correct decision.
Next, I spent another 199 on the Allegretto plan (reimbursable anyway, so who cares). I had already gotten GPT to compute some offsets for the actual phone against the vulnerability repository, and then I jumped straight onto Kimi-K3, throwing it a /goal — a rough prompt along the lines of "find the kernel version closest to the real device, try to reproduce this vulnerability in QEMU, and get a root shell." My phone's kernel version was 5.4.289 at the time; it found a 5.4.200 Ubuntu image, and after several days of repeatedly waiting out the 5-hour limits, it actually got a QEMU root shell — with me "allowing gdb to help inspect the relevant addresses"!
Of course, things didn't go so smoothly afterward. I asked it to try removing the gdb helper, and it kept at it for a long stretch without progress. I tried to get it to adapt the exploit to the real device, and it said it was theoretically impossible. On top of that, my quota was draining fast — and web chat sessions counted against quota too — burning through a month's worth in about two weeks. My senior really wasn't wrong about that.
On August 18, just before the rebuttal, I decided to bite the bullet and spend another 95.4 yuan on a proxy Cyber verification for GPT. It worked — the annoying Cyber pop-ups did largely disappear, though not entirely. My impression at the time was that GPT-5.6 had indeed made some progress. Unfortunately, the next day (the 19th), OpenAI, citing "technical reasons" — namely, some users' Cyber information being lost during the migration to Daybreak — revoked my Cyber status. The seller had explicitly stated that "a successful verification marks the end of the service, no refunds accepted," but seeing that I had only used it for a single day, they refunded me anyway.
After that, I wanted to re-verify right away. But considering that if the rebuttal got my account banned it would hardly be worth it, and that after September 1 there would also be a hardware FIDO key requirement, I decided not to take the risk with Cyber verification again. Instead, I kept exploiting using models without built-in Cyber detection (for example GPT-5.4 / 5.6-luna). This time, not only did the model keep insisting "I found a contradiction in the earlier steps," but I also received three consecutive Cyber abuse emails (although after my appeals they all said sorry and reversed). Things made zero progress either way.
Once the rebuttal was completely over, I went back on Xianyu after September 1 and found that Cyber verification was no longer the few-dozen-yuan deal it used to be — verification alone cost six to seven hundred yuan. Even the acquaintance channel introduced by my senior cost 350, a fully set-up account ran 1000, and Plus/Pro 20x had surged to the absurd heights of 2300/3900. And the verification side had strict account requirements too:
- Never previously verified
- Already subscribed to Plus/Pro
- Account registered for at least one month
More importantly, even if you met all these conditions, passing wasn't guaranteed — and passing didn't guarantee you wouldn't lose Cyber or get banned later. While I could afford those few hundred yuan, I kept agonizing over whether it was worth spending at least 350 + 140 = 490 yuan for this one-time root purchase (since the previous account was already dead), and taking on that much risk all over again — until—
the arrival of DeepSeek V4.1 Flash put an end to my dilemma.
Kicking Off for Real
On September 10, DeepSeek V4.1 Flash officially launched. The costs (during off-peak hours) compared to the original V4 Pro are as follows:
| Model | deepseek-flash | deepseek-v4-pro |
|---|---|---|
| Model version | DeepSeek-V4.1-Flash | DeepSeek-V4-Pro-0813 |
| Context length | 1M | 1M |
| Output length | Max 384K | Max 384K |
| Image understanding | Supported | Not supported |
| Per million tokens input (cache hit) | 0.02 CNY | 0.15 CNY |
| Per million tokens input (cache miss) | 1 CNY | 4.5 CNY |
| Per million tokens output | 4 CNY | 13.5 CNY |
| Concurrency limit | 2500 | 500 |
More importantly, the Cyber capabilities:
| Benchmark | V4 Pro 0813 | V4.1 Flash | Change |
|---|---|---|---|
| CyberGym | 83.3 | 88.1 | +4.8 |
| SEC-Bench Pro | 56.4 | 62.8 | +6.4 |
| ExploitGym | 5.4 | 15.3 | +9.9, roughly 2.83× |
We can draw two conclusions:
- My kernel pwn scenario definitely involves long conversations, where the context is the code for the entire exploitation chain plus the corresponding run logs, and involves repeated debugging. This pushes the cache hit rate very high (in practice, it stayed above 99% most of the time). A quick back-of-the-envelope calculation shows that for the same number of tokens, V4.1 Flash costs less than 1/7 of V4 Pro.
- As for benchmarks: since we already know what the vulnerability is, the task at hand is the entire process of getting a root shell from that vulnerability, so ExploitGym is the more relevant benchmark. The official metric measures the proportion of cases solved within 2 hours — and V4.1 Flash is nearly 3× Pro on that. Plus, my patience clearly extends well beyond two hours, so whether it can reach the final goal, Flash still looks promising.
On top of that, DeepSeek itself won't hit you with Cyber pop-ups the way GPT does, interrupting your work entirely. So as long as it can actually make progress — and doesn't go around in circles like GPT-5.4 / 5.6-luna — it's worth a shot. So I started pouring money into the DeepSeek API, gearing up for the next round of the assault.
Which MagicUI/MagicOS version to choose?
My initial choice was to continue from the partial adaptation that Kimi/GPT 5.4 had done on MagicOS 8 (Linux 5.4.242). But then DeepSeek told me a brutal fact:
MagicOS 8's sepolicy has no ashmem rules for shell!
And this happens to be the most core step in IonStack — so this version is a dead end, and all the offsets computed earlier became worthless.
No way around it, I had to pick up the infrastructure I had prepared earlier and downgrade. A basic security intuition is that the lower the version, the more vulnerabilities must have already been discovered — and indeed, I found that version 5.0.0.189 has at least two CVEs that can be triggered:
- CVE-2026-43499 — this very IonStack.
- CVE-2022-20421 — Binder race → binder_proc spinlock UAF.
The latter, however, is a race, so its success rate isn't very high; whereas IonStack has a 97% success rate, so naturally I went with IonStack.
How to direct "the three cobblers"?
My approach was to split the three LLMs into one master and two slaves:
- The master node works with the original CyberMeowfia repository and can see the official PoC and exploit. It only gives abstract next-step plans and does not directly operate the slaves' QEMU instances or adb-connected phones.
- The slave nodes take plans from the master, write the debug scripts they generate into clean git repositories they create themselves, and — after each step — faithfully write up a status report and commit it.
- The reason for using two slave nodes: some tasks are mutually independent, and running them in parallel speeds up progress.
For data exchange, the master can check the reports in the slaves' repositories to gauge the project's progress and give targeted next steps. As for the slaves, I deliberately did not give them the master's repository path, in order to avoid "attention interference" and to prevent them from absorbing the wrong offsets produced during earlier debugging with Kimi/GPT 5.4.
So what was the trigger? I fed the master node two Nebula links (the aforementioned ionstack-part-2/3), then gave it the project context:
This is an Honor Magic 3 device. Try reading device information, and start porting the PoC following the steps in these two links and the repository you have.
I also emphasized the legitimate security research background, fed it the five steps I mentioned in the Early Exploration section, and the gears of fate started turning.
Of course, V4.1 Flash still hallucinates, and the master itself can make mistakes. So before QEMU was pwned, my prompts also required that after reviewing and verifying each slave report, the master itself had to invoke an adversarial subagent with zero context, to check the master's own summary reports for inconsistencies.
Of course, the adversarial subagent itself might also hallucinate, but the probability of both happening together is already quite low — not enough to affect this project — so I chose to ignore the impact of this possibility.
The remaining question is whether you want to automate further. You could use another LLM with good computer-use capabilities (say, GPT-6 Astra) to shuttle prompts between sessions. But in my experience, each prompt's task takes at least 40 minutes or more, so the frequency isn't high. On the contrary, I think the more important thing is to keep an eye on the next-step plans the master lays out and check whether anything obviously goes off track — and occasionally, it really did happen a few times.
So unless an LLM's long-horizon memory can genuinely still recall what the truly important mission/step was from over a hundred prompts ago (note: this is not a hallucination problem), human-in-the-loop is still necessary. After all, the tokens and time saved may genuinely outweigh the human labor cost.
Can we go further after root?
The answer right now is No.
Although this work yields uid=0(root) gid=0(root) groups=0(root), the context is still u:r:shell:s0:c999, and in this phone's SELinux policy version, file read/write permissions sit with init or fs_type:filesystem mount, not the current shell domain. The SELinux policy is baked in at compile time by Honor — even with root privileges, you can't turn it off.
Of course, I did try switching the SELinux label from u:r:shell:s0 to u:r:init:s0, and mount("tmpfs", MPS[0], "tmpfs", 0, NULL) returned 0. So domain-switched mounting does work — it's just that without an unlocked bootloader, AVB prevents any modification from persisting, and Magisk remains out of the question.
Differences from Nebula's Official Approach
This part is heavy on technical details, so I'll just paste DeepSeek's output directly:
Only discussing the IonStack line. The official WP = the five-step chain from the public article; the "on-device facts" can all be verified by line numbers in the repository.
One-line overview
| Official WP step | Done as-is on device? | Difference |
|---|---|---|
① pselect stack reclaim + fake rt_mutex_waiter |
Structure changed | Landing offset made explicit (shift=10); two fields don't exist on this device; the default "image address shape" was empirically refuted, replaced with a self-referencing shape in an owned page |
② boot_id/loggers KASLR leak |
Approach kept, trustworthiness refuted | Offset switched to alias conversion; plus boot_id polling as an oracle for "did the write land"; additionally, an ashmem_fops alias readout for the slide that the official writeup doesn't have |
③ ashmem fops → configfs handlers |
Same slots, different functions | Uses configfs_read_file/configfs_write_bin_file; legacy jump-table CFI requires "scan for b <body> to find the slot" — the *.cfi_jt symbol addresses in kallsyms are wrong |
④ pipe_buffer → arbitrary R/W |
Same mechanism, opposite precondition | Official: VMEMMAP/physmap fixed, direct conversion possible; on-device linear map is randomized, must probe aliases first |
| ⑤ cred + SELinux + seccomp | Same | All constants localized; selinux_state is a "+0x1" field within a symbol; first went through a kallsyms extractor flaw that required a full re-export of the block |
Preliminaries (determine every subsequent step)
Different CLI/CFI generation — this is the biggest structural difference. The official writeup says Android switched to signature-based kCFI starting in 2022, so "same-signature replacement" suffices. This device is Android 11 / 5.4.86, using the legacy jump-table CFI — kallsyms is full of *.cfi_jt symbols. So:
- The values written into the fake fops must be jump table slots, not function bodies;
- And the symbol address of
*.cfi_jtdoes not point to the function its name suggests (empirically: theconfigfs_read_file.cfi_jtslotbs to 0x563324 — an entirely different function). The reliable method is a two-step: "get the function body address from the symbol table → scan the entire Image forb <body>". On 8.0.0.117, all 4 constants had to be scrapped and re-extracted (CONFIGFS_READ_ITER 0x18b3c50,CONFIGFS_BIN_WRITE_ITER 0x18b499c,NOOP_LLSEEK 0x18aba58,COPY_SPLICE_READ 0x18a0cbc). This step doesn't exist in the official WP's context.
Linear map randomization goes the opposite way. Official: 1db780bafa4c removed arm64 linear map randomization → physmap is fixed, VA↔page conversion works directly. This device is 5.4, which doesn't have that commit; ELZ's target.h explicitly says DIRECT_MAP_BASE/END, VMEMMAP_START are not valid runtime constants — you must probe aliases within KERNELSNITCH_IDENTITY_START..END, and "never treat DIRECT_MAP_BASE/END as truth". For this, util.c splits into two profiles: truephone / sim.
Vendor-private config changes the layout. CONFIG_HN_VIP_THREAD=y makes struct mutex 40 bytes, and page/needs_read_fill/bin_buffer/bin_buffer_size/cb_max_size in struct configfs_buffer all shift downward (the values differ between the 5.4.86 and 5.4.242 targets — see the comment in target.h). The official target has no such vendor switch.
① Stack reclaim + fake waiter
- Landing offset: the official line is "frame depth and syscall reachability vary by image". On-device it became an explicit constant:
PSELECT_WAITER_WORD_SHIFT 10;target.hnotes "on Android, the pselect reclaim lands 0x10 bytes before the real waiter", so the fake waiter uses a "compact waiter-local layout" (word2-4=tree_entry, 5-7=pi_tree, 8=task, 9=lock, 10=prio, 11=deadline), not the generic shape from the official default. The same spot also records one incident: previously the generic fallbackshift=0was mistakenly used, and the boot_id stayed an ordinary UUID the whole time. - Fields don't exist: this device's
rt_mutex_waiterhas nowake_state, noww_ctx(RT_MUTEX_WAITER_HAS_WAKE_STATE=0/HAS_WW_CTX=0; commitd1edcb4"guard wake_state/ww_ctx writes in put_p9_slide_waiter (fields absent on Honor 5.4)"). In the official WP these two fields are part of the fake waiter; on-device the entire section is disabled. - Default shape empirically refuted (I consider this one the most important). The official default shape points key qwords to fixed in-image addresses (
loggers/boot_id). The comment atslide.c:780-788states: on this device KASLR is enabled at boot, these baked-in addresses are invalid at runtime —rb_erasewould dereference q0 as an rb_node,*q2=q0would land in randomized.data, and the walk would be killed outright. Hence the switch to the R1' self-referencing shape: q0/q3 point to a fake rb node in a direct-map page we own, q2/q5 point to a scratch qword on the same page — the entire write primitive no longer depends on any image slide. - The trigger side gained a consumer/mcast-arm mechanism not in the official writeup (
SLIDE_CARRIER_NO_CONSUMER, etc.), used to freeze the walk at the right moment.
② KASLR leak
- The official route (limited write pointing
sysctl_bootid.datatologgers[0][1], reading/proc/sys/kernel/random/boot_idas a UUID to recover&nfulnl_logger) is preserved in the on-device code:slide.c:1981-2035 slide_read_stext(), andSLIDE_NFULNL_LOGGER_OFF/SLIDE_LOGGERS_0_1_OFF/SLIDE_RANDOM_BOOT_ID_DATA_OFF/SLIDE_SYSCTL_BOOTID_OFFare all intarget.h. - But there are three differences:
- The slide is computed using
p0_alias_image_offset(SLIDE_NFULNL_LOGGER)(alias/physical offset conversion), not the official vmlinux-known-offset approach — because the provenance of theSLIDE_*anchors wasn't re-exported on this build;target.hexplicitly says they "cannot be treated as verified". - An extra full-window boot_id polling thread (
slide.c:205, commits R1.5/R1.6) treats "did the boot_id become a UUID" as the oracle for whether the limited write landed; R1.5's conclusion was no overwrite observed. The official writeup reads once and needs no oracle. - An additional independent slide route on-device:
fops.c:684 leak_kernel_base()reads theashmem_fopstable via its linear-map alias (in the image this table is all zeros; the real pointers are filled in by.rela.dynat boot), back-computeskaslr_base = ashmem_open_ptr - (ASHMEM_OPEN - KIMAGE_TEXT_BASE), then cross-checks the ioctl/mmap/release/show_fdinfo slots (fops.c:719-758), exiting withkaslr_step=1/2on any mismatch. The official writeup has no such route.
- The slide is computed using
③ fops hijack
- Same slots:
fops.c:662-663writesFOPS_READ_ITER_OFF/FOPS_WRITE_ITER_OFF(0x20/0x28) — consistent with the official "replace read_iter/write_iter". - Different handlers: on-device we place
configfs_read_fileandconfigfs_write_bin_file(CONFIGFS_READ_ITER/CONFIGFS_BIN_WRITE_ITER), not the same-namedconfigfs_read_iter/write_iter. Plusllseek→noop_llseek(repair_fake_fops_llseek(),fops.c:646) andsplice_read→generic_file_splice_read(fops.c:669) — the official description mentions only the read_iter/write_iter pair. - The remaining slots (ioctl/compat_ioctl/mmap/open/release/show_fdinfo) are kept at original values and verified one by one (
fops.c:664-670compared against thekaslr_expected_*at722-758). - The ashmem entry path matches the official one:
util.c:476 init_ashmem_path()first tries/dev/ashmem<boot_id>, falling back to scanning/devfor ashmem nodes only if the boot_id can't be obtained — except on-device we had to implement the fallback scan ourselves (compatible with SDK 30).
④ pipe_buffer → full arbitrary R/W
- Official:
VMEMMAPis fixed → direct VA↔struct page*, and changingpipe_buffer.pageupgrades to arbitrary R/W. - On-device: no direct conversion possible; must probe the linear-map alias first;
PIPE_INODE_INFO_SLOTS_PER_PAGE 28,PIPE_*,STRUCT_PAGE_*are all verified with pahole against a kernel compiled with the on-device config (one audit: 49 constants, 0 mismatches). - Intermediate layers absent from the official writeup: the
TMP_PAGE_UNAMEpath +run_tmp_page_uname_stage()(main.c:260-275, also supporting aSLIDE_SKIP_SLIDEdirect-map route), plus the AF_UNIXMSG_PEEKphysical readback channel and the "five-qword write + canary readback" milestone (STATUS.md's r46/r50/r57/r114 sections), and thepipelenoracle (a5630c3/de69c57). The real device has no debugger, so we had to build our own readback to tell whether writes landed; the official team can just rerun in kernelCTF.
⑤ cred / SELinux / seccomp
- Same step, but all constants localized:
SELINUX_ENFORCING_OFF = selinux_state + 0x1(a field within a symbol; on 5.0.0.189 changed from 0x2c9d001 to 0x2c9c001);TIF_SECCOMP_BIT 11,PFA_NO_NEW_PRIVS_BIT 0,SECCOMP_MODE_OFF 0x00/FILTER_COUNT 0x04/FILTER 0x08taken from the Honor arm64 source/config;CRED_*,TASK_CRED_OFF 0x880(also weeded out a regex false positive forptracer_cred). - Extra preliminary work: the device's original 21 symbol constants came from a flawed kallsyms extractor (
image5486's offsets had constants in the high 4 bytes → each value was actually the address of the next symbol), requiring a full re-export (commit6cbbd79). Of those, 19/21 were "the symbol's own kallsyms size", with two exceptions being struct fields (ashmem_misc.fops = +0x10) and an in-symbol field (selinux_state.enforcing = +0x1). The official team has an accurate vmlinux and never faces this kind of source flaw.
What didn't change (so we don't overstate the differences)
- The vulnerability itself is untouched:
FUTEX_WAIT_REQUEUE_PI+FUTEX_CMP_REQUEUE_PIproxy path, danglingpi_blocked_on,rb_erase-based limited write (slide.c:2064). - The reclaim syscall is still
pselect(the original article also mentions the clone/setsockopt/keyctl family). - The
<boot_id>-named ashmem node, and usingASHMEM_SET_NAMEto treatprivate_dataasconfigfs_buffer, both match the official approach.
Quantitative evidence: the device-side code forked entire stages
Diff of same-named stage files against upstream (exploit/src/*.c vs targets/ELZ-AN00-5.0.0.189/*.c):
| File (corresponding WP step) | Upstream lines | On-device lines | + | − |
|---|---|---|---|---|
slide.c (① ② trigger/leak) |
1343 | 2205 | 1320 | 550 |
pipe.c (④ arbitrary R/W) |
845 | 1986 | 1196 | 160 |
util.c (page prep/alias/probe) |
1037 | 1463 | 633 | 233 |
fops.c (③ hijack + slide verification) |
445 | 991 | 535 | 34 |
preload.c |
404 | 536 | 140 | 18 |
main.c (stage orchestration) |
269 | 311 | 99 | 59 |
root.c (⑤) |
446 | 521 | 84 | 14 |
In other words: of the official five steps, step ⑤ (cred/SELinux) was changed the least; steps ①/② (trigger and leak) and ④ (physical R/W preconditions) were changed the most. Additionally, the on-device work built benches the official team doesn't have: a nokaslr root-shell reproduction under qemu-honor-5.4.242 (6457819, reproduced twice) + a CFI smoke harness (37672a2) + sim/truephone dual profiles — first running the Honor 5.4 rtmutex walk and fake waiter shape offline before touching the real device; plus one log-fidelity fix (historical logs lacked fdatasync, so all panic-location inferences were downgraded to "no earlier than the last preserved marker").
In one sentence: the official writeup is a chain on "new kernel + signature-based CFI + fixed physmap + rerunnable playground"; the on-device work is "5.4 vendor kernel + legacy jump-table CFI + randomized linear map + non-rerunnable real device" — so steps ①②④ were nearly rewritten, step ③ swapped handlers and hit the .cfi_jt pitfall, and step ⑤ only needed new constants.
Afterword
If someone were to ask me, "You aren't going to do this in the future, and you don't know every single detail of the entire process—so what was the point of spending over two hundred on it?"
I would answer:
Just getting the thrill of doing it once was enough.
I don't plan to make a living from this, but I'm glad I actually did it at least once in my life.
junyu33@zjy-asus:/mnt/c/Users/junyu33/Desktop$ adb.exe devices
List of devices attached
<redacted> device
junyu33@zjy-asus:/mnt/c/Users/junyu33/Desktop$ bash /home/junyu33/elz-phase2/notes/artifacts/root.sh -v "id"
[check] target-page sha256 = 3720fc0ef95b2c4d654c6a660b211ee00eafac2bb37d85f5697dd483673a19d4
[chain] starting the chain (uid 2000 -> uid 0), about 40 s
=== P1: first bounded kernel write on the phone (uid 2000, no root, no GDB) ===
[0] links: T=0xffffffc012aae588 (ashmem_misc+0x10) read_jt=0xffffffc01182ae6c write_jt=0xffffffc01182b88c open=0xffffffc0101bd7a0 rt_mutex_init_waiter=0xffffffc0103686b4 do_futex=0xffffffc0103b3f04
[S0-ID] uid=2000 euid=2000 gid=2000 CapEff=0x0 CapPrm=0x0 Seccomp=0 NoNewPrivs=0
[S0-ENV] uname=5.4.86-qgki-g33a9ed8573e1
[S0-ENV] getenforce=1 domain=u:r:shell:s0 boot_id=19831966-28f0-4e7a-b177-25b40f829564
[S0-ENV] uptime=45.69
[S0-KNOB] perf_event_paranoid=-1
[S0-KNOB] unprivileged_bpf_disabled=EACCES
[S0-KNOB] perf_event_max_sample_rate=100000
[S0-ZONE] start_pfn=525824 spanned=2095616 MemTotal=7526044 kB => memstart=0x80000000 vmemmap=0xfffffffefde00000 physvirt_offset=0x8080000000 (I49 formula)
[SID] D files=27 distinct=9
[S0-SID] D=9 entries0=1851
[SID] CTX_WRITE 'u:r:init:s0:c999' rc=16 errno=0
[SID] XEC entries 1851->1852 delta=1 ctx='u:r:init:s0:c999' readback='u:r:init:s0:c999' match=1
[S0-SID] init_sid=1870 (formula (entries0-D)+28, fresh=1)
[SID] CTX_WRITE 'u:r:shell:s0:c999' rc=17 errno=0
[SID] XEC entries 1852->1853 delta=1 ctx='u:r:shell:s0:c999' readback='u:r:shell:s0:c999' match=1
[S0-SID] shell_sid=1871 (formula (entries0-D)+28, fresh=1)
[S0-ASH] OPEN(/dev/ashmem19831966-28f0-4e7a-b177-25b40f829564) fd=3 errno=0
[S0-ASH] SET_NAME rc=0 errno=0
[S0-ASH] GET_NAME rc=0 errno=0 back='P1MARK-00001ec4-0000b284' match=1
[S0-ASH] SET_NAME(blob) rc=0 errno=0
[S0-ASH] PRECONDITION OK
[V] victims forked: p1v0=7877 p1v1=7878 p1v2=7879 p1v3=7880 p1v4=7881 | sid types: p1v0=init p1v1=init p1v2=init p1v3=shell p1v4=init
[W] LOCK_PI rc=0 errno=0 tid=7882
[TID] W tid=7882 T0 tid=7883
[PERF] OPEN pid=7882 period=20000 fd=3 data_offset=4096 data_size=262144 errno=0
[DBG] warm ms=534 nkern=20247 nrec=20247 iters=444229
[DBG] warm ms=1076 nkern=40555 nrec=40555 iters=897829
[DBG] warm ms=1612 nkern=60702 nrec=60702 iters=1343429
[DBG] warm ms=2145 nkern=80946 nrec=80946 iters=1788992
[DBG] warm ms=2693 nkern=101683 nrec=101683 iters=2250132
[DBG] warm ms=3230 nkern=121984 nrec=121984 iters=2701596
[DBG] warm ms=3761 nkern=142117 nrec=142117 iters=3148164
[DBG] warm ms=4291 nkern=162190 nrec=150000 iters=3593843
[DBG] warm ms=4815 nkern=182017 nrec=150000 iters=4033486
[chain] ready (uid 0 pty on 127.0.0.1:31337, adb forward active)
--- chain summary (154 lines) ---
[S0-ID] uid=2000 euid=2000 gid=2000 CapEff=0x0 CapPrm=0x0 Seccomp=0 NoNewPrivs=0
[S0-ENV] uname=5.4.86-qgki-g33a9ed8573e1
[S0-ENV] getenforce=1 domain=u:r:shell:s0 boot_id=19831966-28f0-4e7a-b177-25b40f829564
[S0-ENV] uptime=45.69
[S0-ZONE] start_pfn=525824 spanned=2095616 MemTotal=7526044 kB => memstart=0x80000000 vmemmap=0xfffffffefde00000 physvirt_offset=0x8080000000 (I49 formula)
[S0-SID] D=9 entries0=1851
[SID] XEC entries 1851->1852 delta=1 ctx='u:r:init:s0:c999' readback='u:r:init:s0:c999' match=1
[S0-SID] init_sid=1870 (formula (entries0-D)+28, fresh=1)
[SID] XEC entries 1852->1853 delta=1 ctx='u:r:shell:s0:c999' readback='u:r:shell:s0:c999' match=1
[S0-SID] shell_sid=1871 (formula (entries0-D)+28, fresh=1)
[S0-ASH] OPEN(/dev/ashmem19831966-28f0-4e7a-b177-25b40f829564) fd=3 errno=0
[S0-ASH] SET_NAME rc=0 errno=0
[S0-ASH] GET_NAME rc=0 errno=0 back='P1MARK-00001ec4-0000b284' match=1
[S0-ASH] SET_NAME(blob) rc=0 errno=0
[S0-ASH] PRECONDITION OK
[S0-F] warm_iters=5028520 kern=227184 user=91636 records=150000
[S0-SLIDE] d=0x190 samples=28163 candidates=2 best=0x2bc3a00000 votes=27395 second=135
[S0-SLIDE] SLIDE=0x2bc3a00000 runtime_text=0xffffffebd3a80000 runtime_etext=0xffffffebd5240000
[S0-SLIDE] kernel IPs in [_text,_etext)=150000/150000
[S0-F] F_i=0xffffffc02eb5bcd0 F_ii=0xffffffc02eb5bcd0 match=1 F&0x3fff=0x3cd0 (want 0x3cd0) Hf=0xffffffc02eb5c000 dest=0xffffffc02eb5bcb0 delta=0x0
[H2] REGION P: base=0xffffff80e128ac40 table=V=0xffffff80e128ac50 M=0xffffff80e128ad50 (V==base+0x10, M==base+0x110)
[PF] T=0xffffffebd64ae588 (T-8=0xffffffebd64ae580) V=0xffffff80e128ac50 M=0xffffff80e128ad50 | F=0xffffffc02eb5bcd0 = dest+0x20 = Hf-0x330 ; T&7=0
[RQ] CMP_REQUEUE_PI rc=-1 errno=35 tries=1 (EDEADLK=35)
[VGATE] overwritten=1 bpf_errno=-13
[TR1] sched_setattr(w=7882,nice=1)=0 errno=0 walk_ms=0 T=0xffffffebd64ae588 V=0xffffff80e128ac50
[SELF-W1] M.rb_root=0xffffffc02eb5bcd0 M.leftmost=0xffffffc02eb5bcd0 M.owner=0x0 | chain_ran=1 F_is_Wstack_3cd0=1 M_mod_0x4000=0x3cd0
[5d] OWNER REPAIR (second NT_PRFPREG write, before the first open) *(V+0x00)=0x0 (want 0)
[7B] KREAD *(V+0x10)=0xffffffebd522ae6c (==read_jt: 1) *(V+0x18)=0xffffffebd522b88c (==write_jt: 1)
[E0] TSECW tsec=0xffffff80c466df40 off=0xf44 sid=1870 n=4 (chain-owned pipe write)
[E0] TSECW SKIPPED (sid=1870 tsec=0xffffff80b392d980)
[E0] TSECW tsec=0xffffff80b3bf99c0 off=0x9c4 sid=1871 n=4 (chain-owned pipe write)
[E0] TSECW tsec=0xffffff80f74f3940 off=0x944 sid=1870 n=4 (chain-owned pipe write)
[P1C-DAEMON] RESIDENT anchor_pt=/dev/pts/0 anchor_fd=3 window_pipe=held_by_parent listening=127.0.0.1:31337 uid=0 pid=7880
[P1C-DAEMON] each connection forks a session child with its OWN pty+shell; this process never exits
[VREP0] role=POS(init: mount/ACC) site_sid=1870 uid=0 gid=0 euid=0 egid=0 cap_eff=ffffffffffffffff cap_prm=ffffffffffffffff mount=0 errno=0 mount_tried=1
[VREP1] role=NEG1(cred only) site_sid=1870 uid=0 gid=0 euid=0 egid=0 cap_eff=ffffffffffffffff cap_prm=ffffffffffffffff mount=-1 errno=13 mount_tried=3
[VREP2] role=NEG2(page-aligned) site_sid=1870 uid=2000 gid=2000 euid=2000 egid=2000 cap_eff=0000000000000000 cap_prm=0000000000000000 mount=-1 errno=13 mount_tried=3
[VREP3] role=POS(shell: console) site_sid=1870 uid=0 gid=0 euid=0 egid=0 cap_eff=ffffffffffffffff cap_prm=ffffffffffffffff mount=-1 errno=0 mount_tried=0
[VREP4] role=(null) site_sid=1870 uid=0 gid=0 euid=0 egid=0 cap_eff=ffffffffffffffff cap_prm=ffffffffffffffff mount=-1 errno=0 mount_tried=0
[CRIT] pos_uid0=1 pos_gid0=1 pos_caps=1 pos_mount0=1 neg1_mount=-1 neg2_uid=2000 init_sid=1870 count_win=1848 sid_written=1 page_ctl=0
[END] *(T) restored to 0xffffffebd5cba298 (== ashmem_fops+slide: 1)
[T] round segment = 24680 ms
--- end summary ---
[shell] id
id
:/data/local/tmp # id
uid=0(root) gid=0(root) groups=0(root),1004(input),1007(log),1011(adb),1015(sdcard_rw),1028(sdcard_r),3001(net_bt_admin),3002(net_bt),3003(inet),3006(net_bw_stats),3009(readproc),3011(uhid) context=u:r:shell:s0:c999
:/data/local/tmp #