<?xml version="1.0" encoding="UTF-8"?><rss version="2.0" xmlns:content="http://purl.org/rss/1.0/modules/content/"><channel><title>shuumaku&apos;s blog</title><description>blog blog</description><link>https://blog.shuumaku.net/</link><language>en</language><item><title>Trying to Boot a Custom Kernel on the ALT MIVE Style Folder 2 (M140J)</title><link>https://blog.shuumaku.net/posts/thor2-geniezone-rabbit-hole/</link><guid isPermaLink="true">https://blog.shuumaku.net/posts/thor2-geniezone-rabbit-hole/</guid><description>Notes on trying to boot a custom kernel on a rooted MediaTek flip phone, and trying to remove its hypervisor.</description><pubDate>Fri, 04 Sep 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;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&apos;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.&lt;/p&gt;
&lt;p&gt;This has not been easy. So far, I did all of this:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Flashed a community KernelSU kernel to a spare boot slot.&lt;/li&gt;
&lt;li&gt;Reverse engineered ARM Trusted Firmware from a TEE partition.&lt;/li&gt;
&lt;li&gt;Found and verified two bootloader patches. A second, independent check
agreed with the first check. The fix still did not work on the device.&lt;/li&gt;
&lt;li&gt;Did a real hard power cycle as a test.&lt;/li&gt;
&lt;li&gt;Found a bug that does not seem to exist anywhere in the code that
should cause it.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;This post includes the commands, the log lines, and the disassembly for
each claim below.&lt;/p&gt;
&lt;h2&gt;The problem&lt;/h2&gt;
&lt;p&gt;Here is the boot chain for context. The chain is: Preloader, then LK (the
bootloader), then ARM Trusted Firmware at EL3, then &lt;a href=&quot;https://gitgud.io/aosp/platform/external/crosvm/-/blob/aml_cfg_351010000/hypervisor/src/geniezone/README.md&quot;&gt;GenieZone&lt;/a&gt; (MediaTek&apos;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.&lt;/p&gt;
&lt;p&gt;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.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;====&amp;gt; GZ (0)[    9.788983]trap lk2linux smc back!
====&amp;gt; GZ (0)[    9.788986]boot linux-64 via EL2, pc=0x40080000, x0=0x47880000, x1=0x0, mode=0x3c5
Successfully open fdt at 0x42000
[pmic_check_rst] AP Watchdog
#T#Preloader Start=1
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;pc=0x40080000&lt;/code&gt; is the real kernel entry point. &lt;code&gt;mode=0x3c5&lt;/code&gt; 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.&lt;/p&gt;
&lt;p&gt;My first idea was this: GenieZone checks the kernel&apos;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?&lt;/p&gt;
&lt;h2&gt;Removing the hypervisor with the partition table&lt;/h2&gt;
&lt;p&gt;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 &lt;a href=&quot;https://github.com/R0rt1z2/fenrir&quot;&gt;Fenrir&lt;/a&gt; studies a
related MediaTek boot bug. Both of these helped guide this work.&lt;/p&gt;
&lt;p&gt;The trick is this. Point the GPT partition entry for the &lt;code&gt;gz&lt;/code&gt; partition
to an invalid location. The Preloader is the first bootloader stage. The
Preloader will fail to read the &lt;code&gt;gz&lt;/code&gt; partition. The Preloader will log a
message and boot without the hypervisor.&lt;/p&gt;
&lt;p&gt;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.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;struct.pack_into(&apos;&amp;lt;Q&apos;, data, gz_b[&apos;offset&apos;] + 32, total_lbas)
struct.pack_into(&apos;&amp;lt;Q&apos;, data, gz_b[&apos;offset&apos;] + 40, total_lbas)  # one past end of disk
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;I applied this patch to the real GPT with
&lt;a href=&quot;https://github.com/bkerler/mtkclient&quot;&gt;mtkclient&lt;/a&gt;:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;mtk.py wo 0 0x4400 gpt_complete_patched.bin --parttype user      # primary GPT
mtk.py wo 0x74b7fbe00 0x4200 sgpt_patched.bin --parttype user    # secondary (backup) GPT
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;This worked on the main boot slot months ago:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;[storage] raw_read fail
[GZINIT] invalid &apos;gz&apos; part length: -1
[GZINIT] gz part check failed: 6!
#T#gz_pre_init=2                          &amp;lt;- about 2 milliseconds. fast and clean.
...(normal boot continues, no hypervisor anywhere)...
[0] welcome to lk
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;This 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 &lt;code&gt;jump to linux kernel 64Bit&lt;/code&gt;. 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&apos;s own fallback code for this step does
not work correctly on this phone.&lt;/p&gt;
&lt;p&gt;This changes the real question. The question is not &quot;how do I get past
verification.&quot; The question is &quot;how do I fix the fallback, or copy what
GenieZone does, in a place I can read and edit.&quot;&lt;/p&gt;
&lt;h2&gt;A bug that looks like someone else&apos;s bug&lt;/h2&gt;
&lt;p&gt;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.&lt;/p&gt;
&lt;p&gt;ARM Trusted Firmware sits next to GenieZone in the boot chain. It lives
inside the &lt;code&gt;tee&lt;/code&gt; partition. It is not encrypted. So I used Ghidra to read
it.&lt;/p&gt;
&lt;p&gt;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.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;0x47d82c1c: bl   #0x47d82b58        ; check: is the target image 64-bit?
0x47d82c20: cbz  w0, #0x47d82c38
0x47d82c30: mov  w0, #0x3c9         ; SPSR_64(EL2h, all exceptions masked)
0x47d82c34: b    #0x47d82c48
&lt;/code&gt;&lt;/pre&gt;
&lt;pre&gt;&lt;code&gt;0x47d845c4: mov  w0, #0            ; this is the whole function
0x47d845c8: ret
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;0x3c9&lt;/code&gt; is the correct value. &lt;code&gt;0&lt;/code&gt; decodes to &lt;code&gt;EL0t&lt;/code&gt;. A kernel cannot boot
into &lt;code&gt;EL0t&lt;/code&gt;. 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.&lt;/p&gt;
&lt;p&gt;There is a strange match here too. That same two-instruction stub, &lt;code&gt;mov w0, #0; ret&lt;/code&gt;, is also the exact payload used in a &lt;a href=&quot;https://github.com/R0rt1z2/fenrir&quot;&gt;public MediaTek exploit
called Fenrir&lt;/a&gt;. Fenrir uses it to
disable a different check on a different device family.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;&apos;sec_get_vfy_policy&apos;: PatchStage(
    &apos;sec_get_vfy_policy&apos;,
    pattern=&apos;00 01 00 b4 fd 7b bf a9&apos;,
    replacement=&apos;00 00 80 52 c0 03 5f d6&apos;,   # mov w0, #0 ; ret, same bytes
    description=&quot;Don&apos;t enforce secure boot policy&quot;,
),
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;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. &quot;Return zero&quot; compiles to the shortest possible
function, no matter what the function is for. It is still a fun match.&lt;/p&gt;
&lt;p&gt;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&apos;s branch.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;  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 branch
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;I checked both patches against a fresh disassembly before I touched real
hardware.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;$ llvm-objdump -d tee_b_patched.bin | grep -A1 47d845c4
47d845c4: mov w0, #0x3c5
47d845c8: ret
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;Testing it on real hardware&lt;/h2&gt;
&lt;p&gt;I flashed a full custom kernel anyway. This felt like a good time to also
try &lt;a href=&quot;https://github.com/WildKernels/GKI_KernelSU_SUSFS&quot;&gt;GKI_KernelSU_SUSFS&lt;/a&gt;.
This is a community kernel with KernelSU built in. It uses the same
upstream source tag as the phone&apos;s stock kernel.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;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 2025
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;It even uses the same compiler version as the stock kernel. If this
kernel booted, it should be useful, not just a test.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;First attempt.&lt;/strong&gt; I left the hypervisor partition blank. I used the
patched ARM Trusted Firmware. I used the new kernel.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;[PART] partition hdr (0)
[PART] image doesn&apos;t exist
[GZINIT] gz_b part. ATF load fail
[BLDR] Second Bootloader Load Failed
PL fatal error...
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;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 &quot;not set up, that is
fine.&quot; The Preloader treats a broken partition as &quot;this is corrupt, stop.&quot;&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Second attempt.&lt;/strong&gt; I copied a real, working hypervisor image into the
test slot. I checked the copy with a checksum first.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;$ adb shell &quot;su -c &apos;md5sum /data/local/tmp/gz_a_live.bin /data/local/tmp/gz_b_live.bin&apos;&quot;
c31b74e963085419cf726ec973feaa80  gz_a_live.bin
c31b74e963085419cf726ec973feaa80  gz_b_live.bin    # identical, byte for byte
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The Preloader still rejected it.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;[GZINIT] invalid &apos;gz&apos; part length: -1
[GZINIT] gz part check failed: 6!
...
[LK]jump to K64 0x40080000
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;(no crash log after this line. the device just reset, the same as the
earlier no-hypervisor failure)&lt;/p&gt;
&lt;p&gt;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.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Third attempt.&lt;/strong&gt; I went back to the GPT trick from before. This time I
applied it to the test slot&apos;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.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;metadata.slot_info[0].retry = 1, 7
metadata.slot_info[0].retry = 1, 6
metadata.slot_info[0].retry = 1, 5
metadata.slot_info[0].retry = 1, 0     &amp;lt;- gave up, fell back to the safe slot
&lt;/code&gt;&lt;/pre&gt;
&lt;pre&gt;&lt;code&gt;[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                     &amp;lt;- 7.26 seconds. long enough to trip a watchdog.
#T#Preloader Start=1                    &amp;lt;- reset, before the boot ever reaches the bootloader
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Here is a clean run, from the very same log file:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;#T#gz_pre_init=2
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;I tried this three times in a row. All three times went the slow way.&lt;/p&gt;
&lt;p&gt;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.&lt;/p&gt;
&lt;h2&gt;A checked fix, checked again, that changed nothing&lt;/h2&gt;
&lt;p&gt;At this point I found what looked like the real cause. The corruption
method sets a partition&apos;s start LBA and end LBA to the same value. This
makes the computed partition size equal the exact size of the read that
&lt;code&gt;gz_pre_init&lt;/code&gt; tries to do.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;0x223a0a: muls  r3, r2, r3      ; total capacity = sector_size * partition_size
0x223a0c: adds.w r1, ip, r5     ; requested = size + offset
0x223a14: subs  r1, r3, r1      ; total - requested
0x223a1a: bhs   #0x223a40       ; if (total &amp;gt;= requested) proceed to hardware
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;total == requested&lt;/code&gt; 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 &lt;code&gt;total(0) &amp;gt;= requested(0x200)&lt;/code&gt;
is false. The check should now fail right away, in software, with no
hardware access at all.&lt;/p&gt;
&lt;p&gt;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.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;- struct.pack_into(&apos;&amp;lt;Q&apos;, data, gz_b[&apos;offset&apos;] + 40, total_lbas)
+ struct.pack_into(&apos;&amp;lt;Q&apos;, data, gz_b[&apos;offset&apos;] + 40, total_lbas - 1)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;I flashed the fix. I read the bytes back to confirm the write.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;$ mtk.py ro 0 0x4400 verify_primary.bin --parttype user
$ cmp gpt_complete_patched.bin verify_primary.bin &amp;amp;&amp;amp; echo MATCH
MATCH
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Then I tested it.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;#T#gz_pre_init=7260
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Same failure. Same delay, to the millisecond. It was as if I had changed
nothing at all.&lt;/p&gt;
&lt;p&gt;I kept going, and stumbled on a real gap that both checks had missed.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;for (iVar4 = tablebase; *(int*)(iVar4+4) != 0; iVar4 += 0x14)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;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&apos;s name. The bounds check never runs at all on
this path. I fixed this gap too. The core problem did not change.&lt;/p&gt;
&lt;p&gt;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, &quot;not found&quot; and &quot;bounds check failed,&quot; reach the exact same
three-instruction handler.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;0x00219ac8: ldr r1, [err_slot]
0x00219aca: str r0, [r1, #0]     ; store whichever error code we got
0x00219acc: bx  lr               ; return. nothing else happens.
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;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.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;#T#gz_pre_init=7260
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;No change. Same delay, at the same point, every time.&lt;/p&gt;
&lt;p&gt;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.&lt;/p&gt;
&lt;h2&gt;Where this stands now&lt;/h2&gt;
&lt;p&gt;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.&lt;/p&gt;
&lt;p&gt;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.&lt;/p&gt;
&lt;p&gt;Next step: try to get a live memory trace, if I can find a way to do it.
Also, sleep.&lt;/p&gt;
</content:encoded></item></channel></rss>