A step-by-step walkthrough of how the F21 Pro's IMEI encryption was reverse-engineered from scratch. Every claim is backed by a command you can run or a byte offset you can verify. No prior knowledge of the format is assumed beyond "this phone stores its IMEI somewhere on disk."
| Source | What it provided |
|---|---|
F21 Pro modem firmware (md1img_a.bin, unpacked via R0rt1z2/md1imgpy to 1_md1rom) |
The compiled ARM code and embedded constants that implement IMEI encryption on the device. Ground truth for key derivation constants, file paths, and call chain. |
| bkerler/mtkclient | Open-source reimplementation of MTK's NVRAM key derivation (SST_Get_NVRAM_SW_Key). Provided the scramble + AES-256-CBC algorithm in Python form. |
| MTK MOLY modem source | Leaked MT6592 modem source. Contains SST_secure.c, custom_nvram_sec.c, and nvram_util.c in C. Older platform — provides the encryption framework and NVRAM structure but predates the MD5-XOR checksum introduced on MT67xx. |
| Black-box testing on live F21 Pro | The MD5-XOR checksum algorithm was determined empirically by decrypting known-good LD0B_001 files and iterating on write/reboot/verify cycles until the modem consistently accepted patched IMEIs. |
On a rooted F21 Pro, the IMEI is readable via service call iphonesubinfo, but where is it stored? MediaTek devices use NVRAM partitions. Search for likely paths:
adb shell su -c "find /mnt/vendor/nvdata -iname '*imei*' 2>/dev/null"
# /mnt/vendor/nvdata/md/NVRAM/NVD_IMEIThe match is a directory; list it to find what's inside:
adb shell su -c "ls -la /mnt/vendor/nvdata/md/NVRAM/NVD_IMEI/"
# total 48
# drwxrwx--x 2 root system 4096 ... .
# drwxrwx--x 7 root system 4096 ... ..
# -rw-r--r-- 1 radio system 36 ... FILELIST
# -rw-rw---- 1 root system 384 ... LD0B_001
# -rw-rw---- 1 root system 96 ... NV01_000
# -rw-rw---- 1 root system 144 ... NV0S_000Four files. LD0B_001 is the 384-byte one, owned root:system mode 0660 — the modem-protected payload, distinguishable by size and ownership from the small companion records. Pull it and inspect:
adb exec-out su -c "cat /mnt/vendor/nvdata/md/NVRAM/NVD_IMEI/LD0B_001" > LD0B_001
wc -c LD0B_001
# 384
od -A x -t x1z -N 32 LD0B_001
# 000000 4c 44 49 00 10 ef 0a 00 0a 00 00 00 0a 40 00 00 >LDI..........@..<
# 000010 00 20 00 00 00 00 00 00 00 00 00 00 00 00 a2 44 >. .............D<
# 000020The file is 384 bytes. The first 4 bytes are LDI\x00 — a known MTK NVRAM file marker. The IMEI digits are not visible in plaintext anywhere in the file, so the content is encrypted. Now the question becomes: with what key, what algorithm, and what format?
All three key-derivation constants live in a contiguous block inside 1_md1rom, sourced from common/service/sst/src/SST_secure_exp.c:
Offset in 1_md1rom Size Constant
────────────────────────────────────────────────────
15637084 (0xEE9A5C) 32 SECOND_SEED
15637116 (0xEE9A7C) 32 KEY_CONST
15637148 (0xEE9A9C) 256 NVSW_KGEN
Immediately after the constants, the source path string confirms their origin:
common/service/sst/src/SST_secure_exp.c
Followed by AES debug strings from the same file:
[CHE] AES encryption, data length is: %d
[CHE] AES enc length should be block size aligned, after aligned, the length is: %d
[CHE] AES decryption, data length is: %d
The NVRAM seed (01 02 03 04 05 06 07 08 09 0A 0B 0C 0D 0E 0F 10 11 12 0B 14 15 16 17 18 19 1A 1B 1C 00 00 00 00) appears at offset 15813496 (0xF14B78).
Found via debug strings in the modem ROM:
custom_nvram_sec.c "pcore/custom/service/nvram/custom_nvram_sec.c"
└── nvram_sec.c "common/service/nvram/sec/nvram_sec.c"
└── SST_secure.c "common/service/sst/src/SST_secure.c"
"[SST]NVRAM secure check, lid=%x, rw=%x,secure=%x"
"[SST]NVRAM secure check status : %x"
debug labels: "enc_seed_source", "enc_key_source",
"enc_seed", "enc_key"
└── SST_secure_exp.c "common/service/sst/src/SST_secure_exp.c"
AES encrypt/decrypt via CHE (Crypto Hardware Engine)
contains SECOND_SEED, KEY_CONST, NVSW_KGEN
The modem validates IMEIs at boot. Evidence from string references:
SBP_IMEI_VERIFY_FAIL_ENTER_ECC_MODE— if IMEI verification fails, the modem enters emergency-calls-only modeSBP_IMEI_LOCK_SUPPORT— carrier IMEI lock support flagsmu_imei_lock_verified_ind_handler— handler called after IMEI lock verificationIMEI locked— debug string when IMEI lock is activeIMEI of SIM— frompcore/modem/nas/mm/cmm/src/mm_cs_common_proc.c
The NVRAM file path Z:\NVRAM\NVD_IMEI appears in the modem's virtual filesystem table at offset 15581844 (0xEDC294), confirming NVD_IMEI is the IMEI storage folder the modem reads from.
The debug strings near the crypto constants tell us the algorithm:
[CHE] AES encryption, data length is: %d
[CHE] AES enc length should be block size aligned, after aligned, the length is: %d
So the modem uses AES. But which mode? Try the simplest first:
- ECB (no IV needed) — try decrypting
LD0B_001[0x40:0x60](the 32 bytes after the 64-byte header) with AES-128-ECB and the derived key. If the output starts with recognizable BCD digits matching the known IMEI, we have our mode. - CBC — would require finding an IV. Try only if ECB doesn't work.
from Crypto.Cipher import AES
AES_KEY = bytes.fromhex("3f06bd14d45fa985dd027410f0214d22")
with open("LD0B_001", "rb") as f:
data = f.read()
# Try ECB on the 32 bytes after the header
pt = AES.new(AES_KEY, AES.MODE_ECB).decrypt(data[0x40:0x60])
print(pt.hex())
# If this starts with valid BCD digits → ECB is correctOn the F21 Pro, ECB works on the first attempt — the decrypted output starts with recognizable BCD-encoded IMEI digits. No IV search needed. The MOLY source (custom_nvram_sec.c) confirms this: custom_nvram_encrypt calls AES with no IV parameter, which in MTK's CHE API defaults to ECB.
Why offset 0x40? If you don't know the header size, brute-force it: try decrypting every 16-byte-aligned offset in the 384-byte file and check which one produces a 15-digit BCD IMEI. The IMEI's BCD form is 8 bytes where the first 7 contain two decimal digits each and byte 7's high nibble is the 0xF padding sentinel for the unpaired 15th digit:
def looks_like_imei_bcd(pt8):
# First 7 bytes: both nibbles must be 0-9
if not all((b & 0xF) <= 9 and (b >> 4) <= 9 for b in pt8[:7]):
return False
# Byte 7: low nibble 0-9 (the 15th digit), high nibble == 0xF (sentinel)
return (pt8[7] & 0xF) <= 9 and (pt8[7] >> 4) == 0xF
for offset in range(0, 384 - 32, 16):
pt = AES.new(AES_KEY, AES.MODE_ECB).decrypt(data[offset:offset+32])
if looks_like_imei_bcd(pt[:8]):
print(f"Candidate at offset {offset:#x}: {pt[:8].hex()}")Only offset 0x40 matches — the trailing 0xF sentinel is the discriminator that rules out offsets where the decrypted bytes are nibble-valid by accident (the all-0xFF padding region in LD0B_001 decrypts to a sequence whose nibbles all happen to be 0–9, but the byte-7 high nibble isn't 0xF). The 384-byte file has an 8-byte signature (LDI\x00\x10\xef\x0a\x00) plus 56 bytes of modem metadata = 64 bytes of header before the first encrypted IMEI block.
The key derivation algorithm was independently documented by bkerler/mtkclient. The constants required are all present in the modem binary (see Crypto constants above):
1. scramble(NVRAM_SEED, KEY_CONST) using SECOND_SEED
→ produces (iv, key), both 32 bytes
2. AES-256-CBC encrypt NVSW_KGEN (256 bytes) with key and iv[:16]
3. Take first 16 bytes of ciphertext = AES-128 NVRAM key
To run the derivation yourself:
from Crypto.Cipher import AES
NVRAM_SEED = bytes.fromhex("0102030405060708090A0B0C0D0E0F1011120B1415161718191A1B1C00000000")
KEY_CONST = bytes.fromhex("3523325342455424438668347856341278563412438668344245542435233253")
SECOND_SEED = bytes.fromhex("8F9C6151DC86B9163A37506D9DFF7753464BA73E5EDEF3625BA18D481235805B")
NVSW_KGEN = bytes.fromhex(
"BE410C67394D98017256AA3C8F21BB42CE75601B8F7BC3078216362B151F7F01"
"96E9EB0431739C7438E4920CB18F0961956BE82D9D68403207B07A3687351302"
"C718AD6B10EB571DCB8CFD250BAA0D55987C19528445B2728BFC252189FEF974"
"46765F5C803309566DB380251A7CE31EB4751A06DBB2B0037B2F391D72B7266D"
"14004905ED85E35901D9E12FE275A9207C01A76183EF175BF894282212EB9266"
"B462B44F3079BB2EC37A9C4749CE9C7DCDE1FB60CB2A177ED103B07F95FAA84C"
"DB156F1B9C90AD25A0A4B6217392886D20D65F182CA1DC42FD908262674CBF74"
"ACD4E5186A44030881C8A213604A001F45F7B30BFCF7DB30D301270C59F7FC10")
def scramble(iv, buf):
iv, buf = bytearray(iv), bytearray(buf)
for i in range(0, 0x20, 2):
iv[i], iv[i+1] = iv[i+1], iv[i]
for i in range(0, 0x20, 2):
buf[i], buf[i+1] = buf[i+1], buf[i]
for i in range(0x20):
v = iv[i] ^ SECOND_SEED[i]
iv[i] = v
buf[i] = v ^ buf[i]
return bytes(iv), bytes(buf)
iv, key = scramble(NVRAM_SEED, KEY_CONST)
derived = AES.new(key, AES.MODE_CBC, iv=iv[:16]).encrypt(NVSW_KGEN)
aes_key = derived[:16]
print(aes_key.hex())
# Output: 3f06bd14d45fa985dd027410f0214d22The constants in the modem binary match mtkclient's values byte-for-byte. The derived key is:
3f06bd14d45fa985dd027410f0214d22
This key is hardcoded in imei_tool.py as AES_KEY rather than re-derived at runtime.
The MD5-XOR checksum is not present in the leaked MOLY source (MT6592-era). It was introduced in the MT67xx modem generation. No public documentation or open-source tool implements it. The algorithm was discovered through the following process:
Pull a known-good LD0B_001 from the device, then decrypt the IMEI block with the derived AES key and inspect it:
# On a rooted F21 Pro:
adb exec-out su -c "cat /mnt/vendor/nvdata/md/NVRAM/NVD_IMEI/LD0B_001" > LD0B_001
# Read the current IMEI for cross-reference:
adb shell su -c "service call iphonesubinfo 4 i32 1" \
| awk -F"'" '{print $2}' | sed '1d' | tr -d '.\n ' | head -c15from Crypto.Cipher import AES
AES_KEY = bytes.fromhex("3f06bd14d45fa985dd027410f0214d22")
with open("LD0B_001", "rb") as f:
data = f.read()
# Encrypted IMEI block is at offset 0x40, 32 bytes
ct = data[0x40:0x60]
pt = AES.new(AES_KEY, AES.MODE_ECB).decrypt(ct)
print("Full 32-byte plaintext (hex):")
print(pt.hex())
print()
# By eye, four regions stand out: 0:8, 8:10, 10:18, 18:32. Print them grouped.
print("Decrypted 32-byte IMEI block:")
for start, end in [(0, 8), (8, 10), (10, 18), (18, 32)]:
chunk = ' '.join(f'{b:02x}' for b in pt[start:end])
print(f" [0x{start:02x} : 0x{end:02x}] {chunk}")Typical output (illustrative — IMEI 123456789012345, BCD 21 43 65 87 09 21 43 f5, stock-style 00 00 filler):
Full 32-byte plaintext (hex):
21436587092143f50000dff6b0a2d850962a0000000000000000000000000000
Decrypted 32-byte IMEI block:
[0x00 : 0x08] 21 43 65 87 09 21 43 f5
[0x08 : 0x0a] 00 00
[0x0a : 0x12] df f6 b0 a2 d8 50 96 2a
[0x12 : 0x20] 00 00 00 00 00 00 00 00 00 00 00 00 00 00
Four regions become obvious:
[0x00:0x08]— eight bytes of decimal-digit pairs that decode as the device's IMEI (BCD encoding, swapped nibbles; matchesservice call iphonesubinfo).[0x08:0x0a]— a 2-byte field, constant for a given IMEI.[0x0a:0x12]— 8 unknown bytes. What are these?[0x12:0x20]— 14 bytes of zero padding.
To confirm the unknown bytes are a checksum (not a nonce or random):
- Pull
LD0B_001multiple times without changing the IMEI → bytes[0x0a:0x12]are identical every time (deterministic, not random) - Change the IMEI on the device, pull again → bytes
[0x0a:0x12]are completely different (dependent on IMEI, not a fixed constant)
This behavior is consistent with a hash-derived checksum over the IMEI data.
Constraints from observation:
- Output is exactly 8 bytes
- Deterministic: same IMEI always produces the same 8 bytes
- High entropy: no obvious pattern, no repeated nibbles, no byte-level structure
- Changes completely when even one IMEI digit changes (avalanche behavior → cryptographic hash, not CRC)
Approach: Pull LD0B_001 files for at least two different known IMEIs (e.g. before and after a carrier swap), decrypt both, and test candidate algorithms against the 8 unknown bytes. The test harness is trivial — one Python script iterating through hypotheses:
import hashlib, struct, binascii
known_pairs = [
# (decrypted_block_from_device_A, known_imei_A),
# (decrypted_block_from_device_B, known_imei_B),
]
for pt, imei in known_pairs:
target = pt[10:18]
data_8 = bytes(pt[0:8]) # BCD only
data_10 = bytes(pt[0:10]) # BCD + 2-byte filler
# --- Hypothesis 1: CRC-64 ---
# No stdlib CRC-64; skip unless nothing else hits.
# --- Hypothesis 2: Simple byte-sum checksum (MOLY style) ---
# nvram_util_caculate_checksum in MOLY uses odd/even byte sums → 2 bytes, not 8.
# Ruled out by size alone.
# --- Hypothesis 3: MD5 truncated to first 8 bytes ---
h = hashlib.md5(data_10).digest()
if h[:8] == target:
print("MD5 first-half match"); continue
# --- Hypothesis 4: MD5 truncated to last 8 bytes ---
if h[8:] == target:
print("MD5 last-half match"); continue
# --- Hypothesis 5: MD5 XOR-folded (first 8 XOR last 8) ---
folded = bytes(h[i] ^ h[i+8] for i in range(8))
if folded == target:
print("MD5 XOR-fold match"); continue
# --- Hypothesis 6: SHA-1 truncated to 8 bytes ---
h1 = hashlib.sha1(data_10).digest()
if h1[:8] == target:
print("SHA-1 first-8 match"); continue
# --- Hypothesis 7: SHA-256 truncated to 8 bytes ---
h256 = hashlib.sha256(data_10).digest()
if h256[:8] == target:
print("SHA-256 first-8 match"); continue
# --- Hypothesis 8: SHA-1 XOR-folded (first 8 XOR bytes 8-16) ---
h1_fold = bytes(h1[i] ^ h1[i+8] for i in range(8))
if h1_fold == target:
print("SHA-1 XOR-fold match"); continue
# --- Hypothesis 9: SHA-256 XOR-folded (first 8 XOR bytes 8-16) ---
h256_fold = bytes(h256[i] ^ h256[i+8] for i in range(8))
if h256_fold == target:
print("SHA-256 XOR-fold match"); continue
# --- Hypothesis 10: MD4 truncated to 8 bytes ---
# MD4 is in some older MTK code paths
try:
h4 = hashlib.new('md4', data_10).digest()
if h4[:8] == target:
print("MD4 first-8 match"); continue
h4_fold = bytes(h4[i] ^ h4[i+8] for i in range(8))
if h4_fold == target:
print("MD4 XOR-fold match"); continue
except ValueError:
pass # MD4 not available in all builds
# --- Hypothesis 11: BLAKE2b with 8-byte digest ---
b2 = hashlib.blake2b(data_10, digest_size=8).digest()
if b2 == target:
print("BLAKE2b-64 match"); continue
# --- Hypothesis 12: repeat all above with data_8 instead of data_10 ---
# (i.e. hash over BCD only, excluding the 2-byte filler)
h_8 = hashlib.md5(data_8).digest()
if bytes(h_8[i] ^ h_8[i+8] for i in range(8)) == target:
print("MD5 XOR-fold over [0:8] match"); continue
# (expand as needed for SHA-1, SHA-256, etc. over data_8)
print("No match found for this pair")Results against an LD0B_001 from the F21 Pro nvdata partition image (illustrative IMEI 123456789012345, stock-style 00 00 filler):
Decrypted plaintext:
[0x00:0x08] BCD: 21436587092143f5 → IMEI: 123456789012345
[0x08:0x0a] Filler: 0000
[0x0a:0x12] Target: dff6b0a2d850962a
[0x12:0x20] Padding: 0000000000000000000000000000
| # | Algorithm | Variant | Input range | Result |
|---|---|---|---|---|
| 1 | Simple byte-sum (MOLY) | odd/even accumulator | any | Wrong size (2 bytes, not 8) |
| 2 | MD5 | first 8 bytes | [0:10] |
No match |
| 3 | MD5 | last 8 bytes | [0:10] |
No match |
| 4 | MD5 | XOR-folded | [0:10] |
MATCH |
| 5 | MD5 | XOR-folded | [0:8] |
No match (wrong input range) |
| 6 | SHA-1 | first 8 bytes | [0:10] |
No match |
| 7 | SHA-1 | XOR-folded | [0:10] |
No match |
| 8 | SHA-256 | first 8 bytes | [0:10] |
No match |
| 9 | SHA-256 | XOR-folded | [0:10] |
No match |
| 10 | MD4 | first 8 / XOR-folded | [0:10] |
N/A (not in hashlib) |
| 11 | BLAKE2b | 8-byte digest | [0:10] |
No match |
| 12 | All of 2-11 | all variants | [0:8] |
No match (BCD-only, no filler) |
Confirmed: MD5 XOR-fold over [0:10] = dff6b0a2d850962a
matches target checksum dff6b0a2d850962a
Note on the data above: the IMEI, BCD, and target checksum in this section are illustrative — the target was computed by applying MD5 XOR-fold, so of course that algorithm matches it. The numbers prove the methodology is internally consistent, not that the algorithm is correct. To verify independently, run this same hypothesis matrix against an LD0B_001 pulled from your own F21 Pro: decrypt the IMEI block with the AES key derived above, take the actual pt[10:18] bytes from your device's plaintext as the target, and confirm that of the 12 candidates only MD5 XOR-fold over pt[0:10] matches. That's where the result becomes evidence rather than self-consistency.
Row 4 is the only hit. The checksum is MD5(plaintext[0:10]) XOR-folded across its two halves:
md = hashlib.md5(bcd_plus_filler).digest() # 16 bytes
checksum = bytes(md[i] ^ md[i + 8] for i in range(8))Why MD5 XOR-fold is the natural first guess among the candidates:
- MD5 is already linked into the modem binary (IPsec cipher suites reference it at offsets 15836083+)
- XOR-folding a digest to halve its length is a standard construction (e.g. NIST SP 800-108 KDF counter mode, Davies-Meyer compression — the pattern "hash then XOR halves" recurs throughout embedded crypto)
- 8 bytes is the natural result of folding MD5's 16-byte output in half — no truncation offset to guess
- The approach is cheap: one MD5 call + 8 XORs, suitable for a modem boot path that runs on every power-on
Note on bytes [0x08:0x0a] (the 2-byte filler): The stock nvdata image has 00 00 at this position. imei_tool.py writes FF FF (matching the convention used by other MTK IMEI tools). Both are accepted by the modem — the checksum is computed over pt[0:10] regardless of what those 2 bytes contain, so any value works as long as the checksum matches. The modem does not validate the filler independently; it only validates the checksum.
All three cases were verified end-to-end on the F21 Pro:
- With correct checksum: write a new IMEI with
MD5(BCD + filler)XOR-folded → reboot → modem accepts it → new IMEI appears inservice call iphonesubinfoand the on-diskLD0B_001reads back as the new IMEI. - Without checksum update (BCD overwritten, checksum left as it was for the previous IMEI): reboot → the modem detects the mismatch and rolls back — it overwrites
LD0B_001with the device's factory IMEI block (BCD + valid factory checksum) sourced from a backup partition.iphonesubinfothen reports the factory IMEI, not the BCD we wrote. - With random checksum bytes at
[10:18]: same outcome as case 2 — the post-rebootLD0B_001is byte-identical to case 2's, confirming both bad-checksum paths trigger the same factory-rollback handler.
This confirms the modem firmware validates the checksum on every boot. The SBP_IMEI_VERIFY_FAIL_ENTER_ECC_MODE symbol earlier in the binary names a stricter failure path the firmware can take (emergency-calls-only with no valid IMEI), but on this device the modem only reaches it when the factory backup is also unrecoverable. With the backup intact, the observable behavior is silent rollback.
The compiled MD5 implementation exists in 1_md1rom (the IPsec/IKE subsystem references it as md5 in lowercase cipher-suite strings starting at offset 15836083 — e.g. aes256-aes128-des-3des-sha256-sha1-aesxcbc-md5-… — and the NVRAM encryption code uses it internally). However, the MD5 function has no debug symbol strings tying it directly to the IMEI checksum — it's a generic library function called from compiled ARM code with no assertion or log strings at the call site. The connection between MD5 and the IMEI checksum was established entirely through the empirical process above.
Once you decrypt a known IMEI and stare at the first 8 bytes, the encoding is recognizable:
Known IMEI: 3 5 0 8 5 9 6 0 0 8 6 2 9 4 8
Bytes: 53 80 95 06 80 26 49 F8
Each byte packs two digits in swapped-nibble order: low nibble = even-indexed digit, high nibble = odd-indexed digit. Byte 7's high nibble is 0xF because the 15th digit is unpaired. This is standard GSM BCD (3GPP TS 23.003, same as SIM card EF_IMSI). It also appears in the MOLY source (nvram_util.c) and older public tools like chuacw/WriteIMEI.
The 2-byte filler at [8:10] plus the MD5-XOR checksum at [10:18] is the structure specific to the MT67xx LD0B_001 format — older platforms (MP0B_001) used a different layout with simple XOR masking and byte-sum checksums. The filler value itself isn't fixed by the format: stock nvdata leaves it 00 00, imei_tool.py writes FF FF, and the modem accepts either as long as the checksum that follows is computed over whatever filler bytes are present.
imei_tool.py component Primary source How verified
────────────────────────────────────────────────────────────────────────────────────────
AES_KEY (hardcoded) Derived via bkerler/mtkclient Constants matched
algorithm from standard MTK seed byte-for-byte in
modem binary at
offsets 0xEE830C+
AES-128-ECB encrypt/decrypt Standard crypto primitive Decrypt known
(pycryptodome) LD0B_001 → valid
BCD IMEI output
imei_to_bcd / bcd_to_imei Standard GSM BCD (MOLY source, Matches decoded
chuacw/WriteIMEI, 3GPP TS 23.003) IMEI from device
_md5_xor_checksum Reverse-engineered from F21 Pro Write/reboot/verify
modem firmware via black-box testing cycle (accepted with
of decrypted LD0B_001 plaintexts correct checksum,
rejected without)
LD0B_001 file layout MOLY source path strings in modem Confirmed by pulling
(header, offsets, size) binary (nvram_multi_folder.c, LD0B_001 from device
NVD_IMEI path at offset 0xEDA51D) and validating size
Plaintext block structure Decrypted live LD0B_001 files from Multiple IMEIs
(BCD + FF FF + checksum + pad) the device, cross-referenced with tested across
iphonesubinfo service call output reboot cycles
Every step above can be independently reproduced with:
- A rooted DuoQin F21 Pro
- The stock modem firmware (
md1img_a.bin) unpacked with md1imgpy - To pull the encrypted file (binary-safe on F21 Pro / Android 11 and later Android + Magisk setups where
su's stdio injects CRLF):The shorteradb shell su -c "cp /mnt/vendor/nvdata/md/NVRAM/NVD_IMEI/LD0B_001 /sdcard/LD0B_001 && chmod 644 /sdcard/LD0B_001" adb pull /sdcard/LD0B_001 adb shell su -c "rm /sdcard/LD0B_001"
adb exec-out su -c "cat …" > LD0B_001form works on F21 Pro / Android 11 but corrupts the pull on Android 13 / Magisk (see Hardware validation (TIQ M5) below for the byte-level evidence). - Python 3.6+ with
pycryptodomeandhashlib(stdlib) to decrypt and test checksum hypotheses adb rebootto verify the modem accepts or rejects the written IMEI
The walkthrough above was conducted on a live F21 Pro (single-SIM). The same key, slot offsets, and checksum were independently re-validated first against a stock DuoQin F25 (dual-SIM) firmware ZIP, then live on F25 hardware via this repo's live_patch.sh and via the flipphoneguy/mtk-imei-switcheroo-app Java port — patched IMEIs persist across reboot and the modem accepts the patched bytes at runtime. F25 hardware testing is performed by the port author (also the F25 device tester); we do not have F25 hardware on this side.
For the MAC-side per-device analysis on F25 (BT_Addr / WIFI signatures, the 01 00 09 00 WIFI header variant, AllMap structure, modem family) see f25_offline_analysis.md.
-
Locate
LD0B_001in the F25 nvdata image. Unzip the F25 firmware, scannvdata.binfor theLDI\x00\x10\xef\x0a\x00signature. Three copies are present: two byte-identical live copies (one active, one ext4 leftover sharing the same 0x40-byte header) and a distinct factory backup at offset0x1c04000. The backup has different IMEIs from the live copies, header bytes[0x2a:0x2c]differ (0x68 0x10live vs0x37 0xf9backup — likely a sequence/version field), and both backup slots carry their own valid checksums. -
Same AES key.
AES.new(0x3f06bd14d45fa985dd027410f0214d22, ECB).decrypt(...)on both 32-byte slots of the F25 liveLD0B_001produces well-formed plaintext: BCD-encoded 15-digit IMEIs at[0:8]of each slot, a 2-byte filler at[8:10](00 00on the live copy,FF FFon the factory backup — both round-trip cleanly because the checksum is computed over whichever bytes are present), and a checksum at[10:18]that matches MD5-XOR overpt[0:10]byte-for-byte. -
Both slots populated. Unlike the F21 Pro (single-SIM, slot 2 = all-zero / all-
0xFF), the F25 has both slots holding real IMEIs. The two IMEIs differ — confirming MTK uses one slot per SIM rather than mirroring. -
Round-trip through
imei_tool.py.imei_tool.py write nvdata.bin <new_imei> -s 1and-s 2against the F25 image: read-back viaimei_tool.py readreturns the new IMEIs in the corresponding slots, the other slot's bytes are byte-identical to the original, and_patch_all_copiesupdates the two header-matching live copies while leaving the factory backup at0x1c04000alone (its differing[0x2a:0x2c]header bytes cause the header-equality matcher to skip it — by analogy with the F21 Pro's factory-rollback handler this is desirable, but rollback behavior on F25 itself has not been observed).
After the initial firmware-only analysis above, F25 hardware was tested via this repo's live_patch.sh and via the Java app port. Patched IMEIs persisted across reboot and the modem accepted the patched bytes at runtime. F25 hardware testing was performed by the port author (also the F25 tester); we do not have F25 hardware here. The original "no live F25 hardware" caveat has been resolved upstream.
- No bad-checksum behavior on F25. The modem-side rollback-vs-ECC-mode response confirmed on the F21 Pro (Step 3 cases 2 and 3 above) has not been deliberately reproduced on F25; the F25 hardware tests only exercised the happy-path (valid checksum, accepted by modem). The next section (Hardware validation, TIQ M5) does cover the bad-checksum path on that device, and the response there is different from F21 Pro: F21 Pro silently restores from factory backup; TIQ M5 deletes the entire
LD0B_001file. Whether F25 follows F21 Pro's rollback behavior or TIQ M5's delete behavior on a bad checksum is not yet known.
Independent end-to-end confirmation on a live TIQ M5 (MT6761, dual-SIM):
-
Firmware-level checks. Same NVRAM crypto framework as F21 Pro / F25:
SST_secure_exp.c,nvram_sec.c,custom_nvram_sec.csource-path strings present in the modem binary; the same standard MTKNVRAM_SEED/KEY_CONST/SECOND_SEEDconstants present at MT6761-specific offsets inmd1img-verified.img; sameZ:\NVRAM\NVD_IMEIIMEI path; sameSBP_IMEI_VERIFY_FAIL_ENTER_ECC_MODE/SBP_IMEI_LOCK_SUPPORTsymbols; modem built withGEMINI_PLUS=2(dual-SIM). -
Decryption check. Pulled
nvdata.binfrom a live device via mtkclient. Four LD0B_001 copies present: three byte-identical 384-byte bodies at offsets0x1202000/0x180414e/0x2e0314eplus one distinct body at0x100214ewhose slot 1 IMEI differs from the others (slot 2 IMEI is the same across all four). All four copies share an identical 0x40-byte header. All four decrypt cleanly with3f06bd14d45fa985dd027410f0214d22; all eight slot blocks (4 copies × 2 slots) carry valid MD5-XOR checksums overpt[0:10]; all fillers are00 00(stock convention). Which of the byte-identical trio is the live ext4 filesystem block versus journal/COW leftovers wasn't determined — the patching strategy doesn't depend on knowing. -
Bug surfaced and fixed. Unlike F25 — where the factory backup's header bytes
[0x2a:0x2c]differ from the live copies and the_patch_all_copiesheader-equality gate correctly excludes it — TIQ M5's four copies share a byte-identical 0x40-byte header. The original_patch_all_copiesblasted the patched-first-copy's 384 bytes onto every header-matching copy, which on TIQ M5 corrupted the live copies' slot 1 (overwriting it with the distinct copy's slot 1 IMEI). The fix patches each copy in place — only the requested slot's 32-byte ciphertext is rewritten per copy. F21 Pro (15-copy real partition image) and F25 (firmware image) produce byte-identical output before and after the fix because their multi-copy scenarios never had body-differing same-header copies; TIQ M5 only works correctly after. -
Live hardware test. Built a test
nvdata.binby chain-patching slot 1 then slot 2 to a single test IMEI (123456789012345); per-copy verification confirmed all 4 copies had both slots = the test IMEI with valid MD5-XOR checksums and zero padding intact. Flashed back via mtkclient, booted the device. Both IMEIs read as123456789012345on-device — confirming the modem accepts patched bytes at runtime, both slots are independently patchable, and the AES key + slot offsets + format + checksum + BCD encoding are all correct on TIQ M5. -
live_patch.shend-to-end (rooted-ADB flow). Two consecutive runs on the same device, each followed by a reboot:- Run 1: dual-SIM
[1/2/n]prompt → choose slot 2 → patch slot 2 to a fresh test IMEI. Post-script: slot 1 byte-identical to its pre-script value (per-copy preservation verified — only the slot-2 ciphertext block changed), slot 2 = the new IMEI. Post-reboot: file md5 byte-identical to script'stmp/patched_LD0B_001.bin(modem persists, no rollback). - Run 2: same prompt → choose slot 1 → patch slot 1 to a fresh test IMEI. Post-script: slot 2 byte-identical to its run-1-patched value (the previously-patched slot is preserved across this run), slot 1 = the new IMEI. Post-reboot: file md5 byte-identical to script's patched file again.
- Observation that drove a script change: the original pull (
adb exec-out su -c "cat $IMEI_PATH" > backup) returned 387 bytes on this device's Android 13 + Magisk combo. Every byte with value0x0ain the file appeared as0x0d 0x0ain the pull — for example the source file's first 8 bytes are4c 44 49 00 10 ef 0a 00("LDI" header), which were pulled back as4c 44 49 00 10 ef 0d 0a 00. The script's defense-in-depth size check (wc -c == 384) correctly rejected it. Pull was switched tocp via suto/sdcard+adb pull(SYNC-protocol-based, binary-safe by construction); same script then verified end-to-end on both TIQ M5 / Android 13 and F21 Pro / Android 11 + Magisk in the same session.
- Run 1: dual-SIM
-
Bad-checksum behavior on TIQ M5: the modem deletes the file. Test: pulled the live
LD0B_001, decrypted slot 1, XOR'd the 8-byte MD5-XOR checksum atpt[0x0a:0x12]with0xff(so the checksum no longer matched MD5-XOR overpt[0:10]), re-encrypted, pushed back. After reboot,LD0B_001was absent from/mnt/vendor/nvdata/md/NVRAM/NVD_IMEI/— only the unrelatedFILELIST,NV01_000, andNV0S_000were left. The other slot (untouched, still valid) didn't save the file: the modem deletes the wholeLD0B_001on a single bad slot. This differs from F21 Pro, which silently rewritesLD0B_001with a factory backup IMEI block (Step 3 cases 2 and 3 above) and keeps the radio up. The TIQ M5 behavior also retroactively explains the initial state of this device when first connected for testing —LD0B_001was missing then too, consistent with a prior bad-checksum write that the modem cleared. Restoration: pushing a validLD0B_001back and rebooting is sufficient; the modem accepts it, persists across reboot, both slots read back correctly. No fastboot / mtkclient flash was needed. -
CRLF-injection layer isolated to
adb exec-out+su -c "...". Test: pushed a 6-byte probe (00 0a 00 0a 00 0a) to/sdcard/, pulled it back five different ways, compared each output against the source.Pull method Output Result adb pull(SYNC protocol)6 bytes ( 000a 000a 000a)clean adb exec-out cat /sdcard/probe(no su)6 bytes clean adb shell cat /sdcard/probe(no su)6 bytes clean adb exec-out su -c "cat /sdcard/probe"9 bytes ( 000d0a 000d0a 000d0a)corrupted adb shell su -c "cat /sdcard/probe"6 bytes clean So the corruption requires the combination of
adb exec-out(which doesn't allocate a PTY) with Magisk'ssu -c "..."invocation. Neither layer alone produces it on this device:adb exec-outby itself pipes raw bytes (test 2).- Magisk's
suby itself, when invoked underadb shell(which does allocate a PTY), produces clean output (test 5) — apparently becausesureuses the parent PTY's terminal settings rather than spawning its own. - Only when
suis invoked underexec-out's no-PTY environment does it appear to allocate its own line-discipline-applying PTY for the executed command, which is what runs\n→\r\n.
The
cp via su /sdcard + adb pullform sidesteps this because the binary content never traversessu's stdout —cpwrites to the filesystem directly, andadb pulluses the SYNC protocol, neither of which involves a PTY.
- (no remaining items — both bad-checksum behavior and the CRLF-layer question are resolved above.)