I have a flip phone. It is a real Android 14 (Go Edition) flip phone, the ALT MIVE Style Folder 2 (model AT-M140J) released in Japan in February 2026. It uses a MediaTek MT6765 chip. It’s rooted via plain Magisk. My goal is to build a custom Linux kernel for it. I want this as a first step toward building custom ROMs for the phone.
This has not been easy. So far, I did all of this:
- Flashed a community KernelSU kernel to a spare boot slot.
- Reverse engineered ARM Trusted Firmware from a TEE partition.
- Found and verified two bootloader patches. A second, independent check agreed with the first check. The fix still did not work on the device.
- Did a real hard power cycle as a test.
- Found a bug that does not seem to exist anywhere in the code that should cause it.
This post includes the commands, the log lines, and the disassembly for each claim below.
The problem
Here is the boot chain for context. The chain is: Preloader, then LK (the bootloader), then ARM Trusted Firmware at EL3, then GenieZone (MediaTek’s own EL2 hypervisor), then the Linux kernel at EL1. GenieZone is the important part here. GenieZone is proprietary. GenieZone is encrypted at rest. GenieZone does more than I first thought.
A custom kernel does not boot on this phone. This is true even when the kernel is a faithful rebuild of the stock kernel. Same source. Same config. The kernel does not fail with an error. The boot reaches GenieZone. GenieZone runs its own self-tests. GenieZone prints a correct handoff log line. Then the device resets. There is no crash dump and no panic message.
====> GZ (0)[ 9.788983]trap lk2linux smc back!====> GZ (0)[ 9.788986]boot linux-64 via EL2, pc=0x40080000, x0=0x47880000, x1=0x0, mode=0x3c5Successfully open fdt at 0x42000[pmic_check_rst] AP Watchdog#T#Preloader Start=1pc=0x40080000 is the real kernel entry point. mode=0x3c5 is the
correct ARM64 SPSR value for EL1h with all exceptions masked. This is the
value the arm64 boot protocol expects. The handoff looks correct in every
way. Then nothing happens. The watchdog resets the device.
My first idea was this: GenieZone checks the kernel’s identity. GenieZone drops any kernel that is not the exact signed image. So the next question was clear. What happens if GenieZone is not there at all?
Removing the hypervisor with the partition table
MediaTek chips have a known trick for this. I did not find this trick on my own. Other researchers have studied related MediaTek devices. A project called Fenrir studies a related MediaTek boot bug. Both of these helped guide this work.
The trick is this. Point the GPT partition entry for the gz partition
to an invalid location. The Preloader is the first bootloader stage. The
Preloader will fail to read the gz partition. The Preloader will log a
message and boot without the hypervisor.
The patch is small. It only changes two 64-bit LBA fields in one 128-byte GPT entry. It also updates the CRC-32 checksum.
struct.pack_into('<Q', data, gz_b['offset'] + 32, total_lbas)struct.pack_into('<Q', data, gz_b['offset'] + 40, total_lbas) # one past end of diskI applied this patch to the real GPT with mtkclient:
mtk.py wo 0 0x4400 gpt_complete_patched.bin --parttype user # primary GPTmtk.py wo 0x74b7fbe00 0x4200 sgpt_patched.bin --parttype user # secondary (backup) GPTThis worked on the main boot slot months ago:
[storage] raw_read fail[GZINIT] invalid 'gz' part length: -1[GZINIT] gz part check failed: 6!#T#gz_pre_init=2 <- about 2 milliseconds. fast and clean....(normal boot continues, no hypervisor anywhere)...[0] welcome to lkThis test showed something new. With GenieZone gone, the bootloader tried
to jump straight into the kernel. That jump also failed. LK finished its
own work. LK logged jump to linux kernel 64Bit. Then the device died,
with no GenieZone log lines this time. So GenieZone does more than check
signatures. GenieZone also sets up the exact CPU state needed to switch
from EL2 to EL1. The bootloader’s own fallback code for this step does
not work correctly on this phone.
This changes the real question. The question is not “how do I get past verification.” The question is “how do I fix the fallback, or copy what GenieZone does, in a place I can read and edit.”
A bug that looks like someone else’s bug
GenieZone is a dead end for static analysis. GenieZone is encrypted. It is only decrypted in RAM at boot time, using hardware-fused keys. There is no way to read its code offline.
ARM Trusted Firmware sits next to GenieZone in the boot chain. It lives
inside the tee partition. It is not encrypted. So I used Ghidra to read
it.
Inside this firmware there are two functions with the same shape. Each function returns a CPU state value, called an SPSR, for the next handoff step.
0x47d82c1c: bl #0x47d82b58 ; check: is the target image 64-bit?0x47d82c20: cbz w0, #0x47d82c380x47d82c30: mov w0, #0x3c9 ; SPSR_64(EL2h, all exceptions masked)0x47d82c34: b #0x47d82c480x47d845c4: mov w0, #0 ; this is the whole function0x47d845c8: ret0x3c9 is the correct value. 0 decodes to EL0t. A kernel cannot boot
into EL0t. Both functions have the same shape and the same job. One of
them returns the wrong value. This looks like a real bug, not a security
check.
There is a strange match here too. That same two-instruction stub, mov w0, #0; ret, is also the exact payload used in a public MediaTek exploit
called Fenrir. Fenrir uses it to
disable a different check on a different device family.
'sec_get_vfy_policy': PatchStage( 'sec_get_vfy_policy', pattern='00 01 00 b4 fd 7b bf a9', replacement='00 00 80 52 c0 03 5f d6', # mov w0, #0 ; ret, same bytes description="Don't enforce secure boot policy",),I do not think this is a shared codebase. I checked the other byte patterns from Fenrir against my own binary. None of them matched. This is most likely a coincidence. “Return zero” compiles to the shortest possible function, no matter what the function is for. It is still a fun match.
So I made two small patches. The first patch fixes the broken helper function. The second patch forces the boot path to use the kernel branch instead of GenieZone’s branch.
0x47d845c4: - mov w0, #0 (00 00 80 52)0x47d845c4: + mov w0, #0x3c5 (a0 78 80 52) ; EL1h, all exceptions masked
0x47d88954: - adrp x0, 0x47da0000 ; ldr w0, [x0, #0x60]0x47d88954: + mov w0, #0 ; nop ; always force the kernel-path branchI checked both patches against a fresh disassembly before I touched real hardware.
$ llvm-objdump -d tee_b_patched.bin | grep -A1 47d845c447d845c4: mov w0, #0x3c547d845c8: retTesting it on real hardware
I flashed a full custom kernel anyway. This felt like a good time to also try GKI_KernelSU_SUSFS. This is a community kernel with KernelSU built in. It uses the same upstream source tag as the phone’s stock kernel.
Linux version 5.10.240-android12-Wild (build-user@build-host) (Android clang version 12.0.5 ...) #1 SMP PREEMPT Fri Sep 5 04:20:00 UTC 2025It even uses the same compiler version as the stock kernel. If this kernel booted, it should be useful, not just a test.
First attempt. I left the hypervisor partition blank. I used the patched ARM Trusted Firmware. I used the new kernel.
[PART] partition hdr (0)[PART] image doesn't exist[GZINIT] gz_b part. ATF load fail[BLDR] Second Bootloader Load FailedPL fatal error...This crashed right away. The boot never reached the bootloader stage. A blank partition and a broken partition are not the same thing to the Preloader. The Preloader treats a blank partition as “not set up, that is fine.” The Preloader treats a broken partition as “this is corrupt, stop.”
Second attempt. I copied a real, working hypervisor image into the test slot. I checked the copy with a checksum first.
$ adb shell "su -c 'md5sum /data/local/tmp/gz_a_live.bin /data/local/tmp/gz_b_live.bin'"c31b74e963085419cf726ec973feaa80 gz_a_live.binc31b74e963085419cf726ec973feaa80 gz_b_live.bin # identical, byte for byteThe Preloader still rejected it.
[GZINIT] invalid 'gz' part length: -1[GZINIT] gz part check failed: 6!...[LK]jump to K64 0x40080000(no crash log after this line. the device just reset, the same as the earlier no-hypervisor failure)
I still cannot explain this. The bytes are identical to a partition that works fine on the main slot. The same bytes fail on the test slot.
Third attempt. I went back to the GPT trick from before. This time I applied it to the test slot’s own GPT entry. Here I learned something new. The same trick that worked months ago can now go two different ways on real hardware. I do not fully control which way it goes.
metadata.slot_info[0].retry = 1, 7metadata.slot_info[0].retry = 1, 6metadata.slot_info[0].retry = 1, 5metadata.slot_info[0].retry = 1, 0 <- gave up, fell back to the safe slot[SD0] DAT TMO error (0x4000), Left: 512/512 bytes, RXFIFO:0[SD0] 32-bit PIO Read Error (1)[SD0] DAT TMO error (0x4000), Left: 512/512 bytes, RXFIFO:0[SD0] 32-bit PIO Read Error (1)... (repeats about 40 more times) ...[storage] raw_read fail#T#gz_pre_init=7260 <- 7.26 seconds. long enough to trip a watchdog.#T#Preloader Start=1 <- reset, before the boot ever reaches the bootloaderHere is a clean run, from the very same log file:
#T#gz_pre_init=2I tried this three times in a row. All three times went the slow way.
This points to two different outcomes for the same trick. In the good outcome, the Preloader sees the bad partition right away and boots on without GenieZone in about 2 milliseconds. In the bad outcome, the storage controller keeps retrying the bad read for several seconds, long enough to trip the watchdog and reset the device before the boot ever reaches the clean fallback. Every recent test run has landed on this slow, bad outcome instead of the fast, good one.
A checked fix, checked again, that changed nothing
At this point I found what looked like the real cause. The corruption
method sets a partition’s start LBA and end LBA to the same value. This
makes the computed partition size equal the exact size of the read that
gz_pre_init tries to do.
0x223a0a: muls r3, r2, r3 ; total capacity = sector_size * partition_size0x223a0c: adds.w r1, ip, r5 ; requested = size + offset0x223a14: subs r1, r3, r1 ; total - requested0x223a1a: bhs #0x223a40 ; if (total >= requested) proceed to hardwaretotal == requested passes this check. There is no margin at all. My fix
was to shift the corrupted range by one sector. This makes the computed
size equal zero instead of matching. Now total(0) >= requested(0x200)
is false. The check should now fail right away, in software, with no
hardware access at all.
I did not trust my own analysis, so I did a second, separate check. The second check had one job: try to prove the first check wrong. It could not. I confirmed the exact branch instruction above, and confirmed there was no off-by-one error. I checked the new address against every real partition on the disk, to rule out a collision.
struct.pack_into('<Q', data, gz_b['offset'] + 40, total_lbas)struct.pack_into('<Q', data, gz_b['offset'] + 40, total_lbas - 1)I flashed the fix. I read the bytes back to confirm the write.
$ mtk.py ro 0 0x4400 verify_primary.bin --parttype user$ cmp gpt_complete_patched.bin verify_primary.bin && echo MATCHMATCHThen I tested it.
#T#gz_pre_init=7260Same failure. Same delay, to the millisecond. It was as if I had changed nothing at all.
I kept going, and stumbled on a real gap that both checks had missed.
for (iVar4 = tablebase; *(int*)(iVar4+4) != 0; iVar4 += 0x14)A partition entry with size zero looks like the end of the table to this loop. My fix sets the size to zero on purpose. So the lookup stops before it ever checks the entry’s name. The bounds check never runs at all on this path. I fixed this gap too. The core problem did not change.
I then traced every remaining branch down to single instructions. I proved, one instruction at a time, that none of these branches can take more than a few microseconds. This includes proof that the two error branches, “not found” and “bounds check failed,” reach the exact same three-instruction handler.
0x00219ac8: ldr r1, [err_slot]0x00219aca: str r0, [r1, #0] ; store whichever error code we got0x00219acc: bx lr ; return. nothing else happens.I also ruled out a stale hardware cache from a soft reset. I did a full, physical power cycle to test this. I held the power button until the phone fully turned off. I waited. I turned it back on.
#T#gz_pre_init=7260No change. Same delay, at the same point, every time.
So far, every possible cause that I could think of was checked, one at a time. Every one of them was ruled out. The problem was still there. This is a strange place to end up. I proved, step by step, that this failure should not happen. It kept happening anyway.
Where this stands now
The phone itself is fine. Every test so far ran on a spare boot slot. The main slot was never touched. The worst case each time was a safe fallback to the working slot, and that is exactly what happened, every time.
The real cause is still unknown. It likely lives below what a disassembler can show me, inside a storage driver I cannot read from the Preloader code alone. Or it lives in some interaction I have not found yet. This needs a different kind of tool than the one I used so far. It does not need more patience with the same tool.
Next step: try to get a live memory trace, if I can find a way to do it. Also, sleep.