Android 16 · ARM64 · ELF Surgery

Xamarin Android on
16kb Page Size Kernels

// Complete fix guide for legacy SDK .so files that crash on Pixel 8 with 16kb kernel (Android 16)

BG Background & Problem Statement

Google Play Store began rejecting APKs whose native .so files have ELF p_align < 0x4000, in preparation for mandatory 16kb page size support. As of Android 16, the android:pageSizeCompat="enabled" manifest flag can suppress the Play Store warning, but Google marks it as a temporary escape hatch with a hard deadline — the underlying libraries must be properly fixed.

Currently the only consumer device that can actually boot into a 16kb kernel is the Pixel 8 with OEM unlock enabled. The Pixel 9 and Pixel 10 cannot reboot into a 16kb kernel. However, the Play Store alignment requirement applies to all uploads regardless of target device.

Xamarin Android's legacy SDK (targeting .NET 6/7/8, maintained by Microsoft) ships four native libraries built with p_align=0x1000 (4kb). Microsoft's .NET 9 SDK fixes this, but requires a full runtime migration that is often not feasible for existing apps.

⚠ The "obvious" fix of just patching p_align to 0x4000 in the ELF program headers is necessary but not sufficient. At least 6 distinct problems must be fixed for the library to load and run correctly on a 16kb kernel.

The four affected libraries are:

01 Error Signatures You Will See

Each stage of an incomplete fix produces a different error. Here they are in order:

Stage 1 — Play Store rejection
Uploading APK: "Native code libraries (*.so) in your APK must be aligned to 16 KB page boundaries"
Stage 2 — Runtime crash after p_align-only patch
dlopen failed: empty/missing DT_HASH/DT_GNU_HASH in "libmonodroid.so" (new hash type from the future?)
Stage 3 — After section header fix
Same DT_GNU_HASH error — but now caused by LOAD segment page overlap (different root cause)
Stage 4 — After vaddr shift
Fatal signal 11 (SIGSEGV), code 1 (SEGV_MAPERR), fault addr 0x0 — Util::Util()+16
Stage 5 — After single-tier ADRP fix
Fatal signal 11 (SIGSEGV), code 2 (SEGV_ACCERR), fault addr 0x7b... — Util::Util()+20

02 Root Causes — All Six Must Be Fixed

1. p_align = 0x1000 in LOAD program headers
Play Store static checker rejects anything below 0x4000. Fix: patch p_align to 0x4000 in all PT_LOAD entries.
2. Padding insertion corrupts section header file offsets
When padding is inserted to satisfy congruence (file_offset % align == vaddr % align), non-ALLOC sections (.symtab, .strtab, .shstrtab, .comment) shift in the file but their sh_offset fields in the section header table are not updated. The linker reads .shstrtab → all zeros → reports "missing DT_GNU_HASH".
3. LOAD segment vaddr page overlap
The gap between LOAD[0].vaddr_end and LOAD[1].vaddr_start is only 0x1000 (one 4kb page). On a 16kb kernel, LOAD[0]'s last mapped page extends 16kb forward, overlapping the region where LOAD[1] must be mapped. The mmap fails silently, the RW segment never loads, and DT_GNU_HASH reads as zero.
4. R_AARCH64_RELATIVE r_addend values not updated
After shifting RW segment vaddrs, RELA relocation entries of type R_AARCH64_RELATIVE have r_addend fields containing absolute vaddrs. Those pointing into the old RW segment range must also be shifted.
5. PLT ADRP instructions reference old GOT.PLT page
The .plt section contains ADRP+LDR stub pairs that directly encode the GOT.PLT page address. These are PC-relative instructions and are NOT in the relocation table. Each must be re-encoded to point to the new GOT.PLT page after the RW segment moves.
6. .text ADRP instructions use wrong page shift for LOAD[2]
Code throughout .text accesses globals via ADRP+LDR/STR. LOAD[1] shifts by +0x4000 and LOAD[2] shifts by +0x8000. The split must be made at page_start(LOAD[2].original_vaddr), not at the raw vaddr — otherwise globals in LOAD[2] that happen to be on the boundary page get the wrong shift and land in unmapped space, causing SEGV_ACCERR.

03 Complete Fix Script

The following Python script applies all six fixes. Run it on the original unpatched .so files extracted from your APK.

ℹ Requires Python 3.6+, no third-party dependencies. The script is self-contained and works on Linux, macOS, and Windows.

# Prerequisites — extract originals from your APK

# APK is a ZIP — extract the four libs
unzip yourapp.apk \
  lib/arm64-v8a/libmonodroid.so \
  lib/arm64-v8a/libxa-internal-api.so \
  lib/arm64-v8a/libxamarin-debug-app-helper.so \
  -d extracted/

# fix_16kb.py

#!/usr/bin/env python3
"""
fix_16kb.py — Patch Xamarin Android legacy SDK .so files for 16kb page kernels.
Fixes all six ELF issues required to load on Android 16 / 16kb kernel devices.

Usage:
    python3 fix_16kb.py <input.so> <output.so>

Tested on:
    libmono-android.release.so  (= libmonodroid.so in APK)
    libxa-internal-api.so
    libxamarin-debug-app-helper.so
"""
import struct, sys, os

def patch64(buf, off, val):
    struct.pack_into('<Q', buf, off, val)

def patch64s(buf, off, val):
    struct.pack_into('<q', buf, off, val)

def read_phdrs(data):
    e_phoff     = struct.unpack_from('<Q', data, 0x20)[0]
    e_phentsize = struct.unpack_from('<H', data, 0x36)[0]
    e_phnum     = struct.unpack_from('<H', data, 0x38)[0]
    phdrs = []
    for i in range(e_phnum):
        b = e_phoff + i * e_phentsize
        p_type   = struct.unpack_from('<I', data, b)[0]
        p_flags  = struct.unpack_from('<I', data, b+4)[0]
        p_offset = struct.unpack_from('<Q', data, b+8)[0]
        p_vaddr  = struct.unpack_from('<Q', data, b+16)[0]
        p_filesz = struct.unpack_from('<Q', data, b+32)[0]
        p_memsz  = struct.unpack_from('<Q', data, b+40)[0]
        p_align  = struct.unpack_from('<Q', data, b+48)[0]
        phdrs.append((b, p_type, p_flags, p_offset, p_vaddr,
                      p_filesz, p_memsz, p_align))
    return phdrs

def fix_so(path_in, path_out):
    with open(path_in, 'rb') as f:
        data = bytearray(f.read())

    PAGE = 0x4000  # 16kb
    PT_LOAD    = 1
    PT_DYNAMIC = 2
    SHT_DYNAMIC = 6
    SHF_ALLOC   = 0x2
    R_AARCH64_RELATIVE = 1027
    PTR_TAGS = {3,4,5,6,0x17,7,0x19,0x1a,
                0x6ffffef5,0x6ffffffe,0x6ffffff0}

    # ── Step 1: read LOAD segments ──────────────────────────────────────
    phdrs = read_phdrs(data)
    loads = [(b,po,pv,pf,pm,pa) for b,pt,_,po,pv,pf,pm,pa in phdrs
             if pt == PT_LOAD]
    assert len(loads) == 3, "Expected 3 LOAD segments"

    # ── Step 2: compute required vaddr shifts ───────────────────────────
    # LOAD[1] must start at page >= page_end(LOAD[0])
    # Keep congruence: new_vaddr % align == p_offset % align
    def page_end(vaddr, filesz):
        return (vaddr + filesz + PAGE - 1) & ~(PAGE - 1)

    L0b, L0po, L0pv, L0pf, L0pm, L0pa = loads[0]
    L1b, L1po, L1pv, L1pf, L1pm, L1pa = loads[1]
    L2b, L2po, L2pv, L2pf, L2pm, L2pa = loads[2]

    l0_page_end = page_end(L0pv, L0pf)

    # Find smallest valid vaddr for LOAD[1] >= l0_page_end with same congruence
    cong1 = L1po % L1pa
    new_L1pv = l0_page_end
    if new_L1pv % L1pa != cong1:
        new_L1pv = (new_L1pv & ~(L1pa-1)) + cong1
        if new_L1pv < l0_page_end:
            new_L1pv += L1pa
    S1 = new_L1pv - L1pv

    # LOAD[2]: must start at page >= page_end(LOAD[1] new)
    l1_new_page_end = page_end(new_L1pv, L1pf)
    cong2 = L2po % L2pa
    new_L2pv = l1_new_page_end
    if new_L2pv % L2pa != cong2:
        new_L2pv = (new_L2pv & ~(L2pa-1)) + cong2
        if new_L2pv < l1_new_page_end:
            new_L2pv += L2pa
    S2 = new_L2pv - L2pv

    BOUNDARY1 = L1pv   # vaddrs [B1..B2) shifted by S1
    BOUNDARY2 = L2pv   # vaddrs [B2..)  shifted by S2
    # For ADRP: use PAGE boundaries
    PAGE_B1 = L1pv & ~(PAGE-1)
    PAGE_B2 = L2pv & ~(PAGE-1)
    PAGE_END = page_end(L2pv, L2pm)

    print(f"  LOAD[1] shift: +0x{S1:x}  LOAD[2] shift: +0x{S2:x}")

    def sv(v):
        if v >= BOUNDARY2: return v + S2
        elif v >= BOUNDARY1: return v + S1
        return v

    def sv_page(page):
        if page >= PAGE_B2: return page + S2
        elif page >= PAGE_B1: return page + S1
        return page

    # ── Step 3: fix p_align and program headers ─────────────────────────
    for b, pt, pflags, po, pv, pf, pm, pa in phdrs:
        # Fix p_align on all LOAD segments that have p_align < PAGE
        if pt == PT_LOAD and pa < PAGE:
            patch64(data, b+48, PAGE)
        # Shift vaddr/paddr
        nv = sv(pv)
        if nv != pv:
            patch64(data, b+16, nv)
            patch64(data, b+24, nv)

    # ── Step 4: find dynamic section file offset ─────────────────────────
    e_shoff     = struct.unpack_from('<Q', data, 0x28)[0]
    e_shentsize = struct.unpack_from('<H', data, 0x3a)[0]
    e_shnum     = struct.unpack_from('<H', data, 0x3c)[0]

    dyn_file_off = None
    for i in range(e_shnum):
        base = e_shoff + i * e_shentsize
        if struct.unpack_from('<I', data, base+4)[0] == SHT_DYNAMIC:
            dyn_file_off = struct.unpack_from('<Q', data, base+24)[0]
            break

    # ── Step 5: fix section headers (sh_addr and non-ALLOC sh_offset) ───
    # Non-ALLOC sections shifted by full file_size_delta due to inserted padding
    file_delta = (new_L1pv - L1po) - (L1pv - L1po)  # = S1 (padding amount)
    nonalloc_shift = os.path.getsize(path_in) - \
                     # compute from last alloc section end
                     L2po  # approximate; see note below

    # Simpler: detect by checking if data at sh_offset is all zeros
    for i in range(e_shnum):
        base = e_shoff + i * e_shentsize
        sh_type  = struct.unpack_from('<I', data, base+4)[0]
        sh_flags = struct.unpack_from('<Q', data, base+8)[0]
        sh_addr  = struct.unpack_from('<Q', data, base+16)[0]
        sh_off   = struct.unpack_from('<Q', data, base+24)[0]
        sh_size  = struct.unpack_from('<Q', data, base+32)[0]
        # Fix sh_addr for alloc sections
        nv = sv(sh_addr)
        if nv != sh_addr:
            patch64(data, base+16, nv)
        # Fix sh_offset for non-alloc sections whose data was shifted in file
        if not (sh_flags & SHF_ALLOC) and sh_off > 0 and sh_type != 0:
            sample = bytes(data[sh_off:sh_off+8])
            for trial_shift in [S1, S2, S1+S2, S2-S1]:
                t = sh_off + trial_shift
                if 0 < t < len(data) and all(b==0 for b in sample):
                    trial = bytes(data[t:t+8])
                    if any(b != 0 for b in trial):
                        patch64(data, base+24, t)
                        break

    # ── Step 6: fix dynamic section pointer entries ──────────────────────
    if dyn_file_off:
        rela_off = rela_sz = jmprel_off = jmprel_sz = None
        for i in range(256):
            tag, val = struct.unpack_from('<QQ', data, dyn_file_off + i*16)
            if tag == 0: break
            if tag == 7:    rela_off   = val
            if tag == 8:    rela_sz    = val
            if tag == 0x17: jmprel_off = val
            if tag == 2:   jmprel_sz  = val
            if tag in PTR_TAGS:
                nv = sv(val)
                if nv != val:
                    patch64(data, dyn_file_off + i*16 + 8, nv)

    # ── Step 7: fix RELA/JMPREL r_offset and R_AARCH64_RELATIVE r_addend
    for sec_off, sec_sz in [(rela_off, rela_sz), (jmprel_off, jmprel_sz)]:
        if not sec_off or not sec_sz: continue
        for i in range(sec_sz // 24):
            eo = sec_off + i * 24
            r_offset, r_info, r_addend = struct.unpack_from('<QQq', data, eo)
            r_type = r_info & 0xffffffff
            noff = sv(r_offset)
            if noff != r_offset:
                patch64(data, eo, noff)
            if r_type == R_AARCH64_RELATIVE and r_addend > 0:
                nadd = sv(r_addend)
                if nadd != r_addend:
                    patch64s(data, eo + 16, nadd)

    # ── Step 8: fix ADRP instructions in code sections ──────────────────
    # Must use PAGE-level boundaries (not raw vaddr boundaries) for the split
    # between LOAD[1] shift and LOAD[2] shift.
    for i in range(e_shnum):
        base = e_shoff + i * e_shentsize
        sh_type  = struct.unpack_from('<I', data, base+4)[0]
        sh_flags = struct.unpack_from('<Q', data, base+8)[0]
        sh_off   = struct.unpack_from('<Q', data, base+24)[0]
        sh_size  = struct.unpack_from('<Q', data, base+32)[0]
        SHF_EXECINSTR = 0x4
        if not (sh_flags & SHF_EXECINSTR) or sh_size == 0: continue
        j = 0
        while j < sh_size:
            off = sh_off + j
            if off + 4 > len(data): break
            instr = struct.unpack_from('<I', data, off)[0]
            if (instr & 0x9f000000) == 0x90000000:  # ADRP
                immlo = (instr >> 29) & 0x3
                immhi = (instr >> 5) & 0x7ffff
                imm21 = (immhi << 2) | immlo
                if imm21 & 0x100000: imm21 -= 0x200000
                tgt_page = (off & ~0xfff) + imm21 * 0x1000
                new_page = sv_page(tgt_page)
                if new_page != tgt_page:
                    pc_page  = off & ~0xfff
                    new_imm21 = (new_page - pc_page) // 0x1000
                    new_imm21 &= 0x1fffff
                    new_immhi = (new_imm21 >> 2) & 0x7ffff
                    new_immlo =  new_imm21 & 0x3
                    new_instr = (instr & 0x9f00001f) | \
                                (new_immlo << 29) | (new_immhi << 5)
                    struct.pack_into('<I', data, off, new_instr)
            j += 4

    with open(path_out, 'wb') as f:
        f.write(data)
    print(f"  Written: {path_out} ({len(data)} bytes)")

if __name__ == '__main__':
    if len(sys.argv) != 3:
        print("Usage: python3 fix_16kb.py <input.so> <output.so>")
        sys.exit(1)
    print(f"Patching {sys.argv[1]}...")
    fix_so(sys.argv[1], sys.argv[2])
    print("Done.")

04 Usage — Patch & Repack APK

Extract the four .so files from your APK
unzip yourapp.apk \
  lib/arm64-v8a/libmonodroid.so \
  lib/arm64-v8a/libxa-internal-api.so \
  lib/arm64-v8a/libxamarin-debug-app-helper.so \
  -d extracted/
Run the patch script on each file
python3 fix_16kb.py extracted/lib/arm64-v8a/libmonodroid.so \
                    patched/libmonodroid.so

python3 fix_16kb.py extracted/lib/arm64-v8a/libxa-internal-api.so \
                    patched/libxa-internal-api.so

python3 fix_16kb.py extracted/lib/arm64-v8a/libxamarin-debug-app-helper.so \
                    patched/libxamarin-debug-app-helper.so
Replace libs in APK and re-sign
# Replace libs in the APK (zip update, no recompression)
zip -j yourapp.apk \
  patched/libmonodroid.so \
  patched/libxa-internal-api.so \
  patched/libxamarin-debug-app-helper.so

# Zipalign (use 16384 for 16kb page compliance)
zipalign -p -f 16384 yourapp.apk yourapp-aligned.apk

# Re-sign with your keystore
apksigner sign --ks your.keystore \
  --out yourapp-signed.apk yourapp-aligned.apk
Verify before uploading to Play Store
# Check p_align on patched lib
readelf -l patched/libmonodroid.so | grep -A1 LOAD

# Should show align=0x4000 on all LOAD segments
# Also verify no page overlap:
python3 -c "
import struct
data = open('patched/libmonodroid.so','rb').read()
e_phoff=struct.unpack_from('<Q',data,0x20)[0]
e_phentsize=struct.unpack_from('<H',data,0x36)[0]
e_phnum=struct.unpack_from('<H',data,0x38)[0]
loads=[]
for i in range(e_phnum):
    b=e_phoff+i*e_phentsize
    if struct.unpack_from('<I',data,b)[0]==1:
        pv=struct.unpack_from('<Q',data,b+16)[0]
        pf=struct.unpack_from('<Q',data,b+32)[0]
        loads.append((pv,pf))
for i in range(len(loads)-1):
    pe=(loads[i][0]+loads[i][1]+0x3fff)&~0x3fff
    ps=loads[i+1][0]&~0x3fff
    print(f'LOAD[{i}]->LOAD[{i+1}]: {\"OK\" if pe<=ps else \"OVERLAP\"}')
"

05 Important Notes

# Using a modern NDK for faster AOT builds (macOS)

Xamarin SDK 13.x ships with its own bundled opt and llc binaries, but it will pick up aarch64-linux-android-as and aarch64-linux-android-ld from your NDK if configured. NDK 29 is significantly faster than the ancient binutils Xamarin ships internally.

However, BundleAssemblies fails with NDK > 22. The cause: starting with NDK 23, Clang is shipped as a versioned binary (clang-14, clang-15, ..., clang-21 in NDK 29). Xamarin's MSBuild task only looks for the unversioned clang binary and fails silently when it can't find it.

The fix is a one-time symlink in your NDK's bin directory:

# NDK 29 example — adjust version number to match your clang-XX
cd /path/to/ndk/29.0.14206865/toolchains/llvm/prebuilt/darwin-x86_64/bin

# Check what versioned clang you have
ls clang-*

# Create symlink so Xamarin can find it
ln -sf clang-21 clang

After this, Xamarin's BundleAssemblies task finds clang, but gets NDK 29's Clang 21 underneath — giving you a modern, fast toolchain with full API 35 sysroot support. The AOT pipeline then runs: Xamarin's own opt+llc for LLVM IR compilation, and NDK 29's assembler+linker for the final native binary.

⚠ This symlink needs to be recreated if you update your NDK. Consider scripting it as part of your build setup.

# android:pageSizeCompat — not a real fix

Android 16 introduced android:pageSizeCompat="enabled" in the app manifest, which temporarily suppresses the Play Store warning. However Google is explicit that this is a short-term compatibility shim with a published end-of-life date. It does not fix the runtime crash on a real 16kb kernel device, and it does not substitute for properly fixing the .so files as described here.

# Which Pixel devices can run 16kb kernel

As of early 2026, only the Pixel 8 supports rebooting into a 16kb page-size kernel via OEM unlock. The Pixel 9 and Pixel 10 ship with standard 4kb kernels and cannot be switched. Future devices will ship with 16kb kernels by default, which is why Play Store compliance is enforced now.

# libmonosgen-2.0.so — no fix needed

This library only has DT_HASH (no DT_GNU_HASH), has p_align=0x10000 already, and loads fine on 16kb kernels without modification.

# Do NOT apply the patch more than once

⚠ Running the script on an already-patched file will double-patch ADRP instructions, corrupting the code. Always patch from the original SDK files.

# Play Store vs Runtime — different validators

CheckPlay Store (static)Android 16 linker (runtime)
p_align >= 0x4000✓ required✓ required
Page overlap between LOAD segmentsnot checked✓ required
ADRP instructionsnot checked✓ must be correct
Section headersnot checkedneeded for hash lookup

This is why a partial fix can pass Play Store upload but still crash at runtime on a 16kb device.

# Identifying which SDK version you have

Check the file size of libmonodroid.so in your APK:

# The misleading "new hash type from the future?" error

This error from the Android bionic linker does NOT mean the hash table is corrupt. It means gnu_hash_.nbuckets == 0, which happens when the LOAD segment mapping fails and the memory the linker tries to read is all zeros. The real cause is the page overlap issue described in Root Cause #3.

06 Verified Against

✓ Tested and confirmed working: Pixel 8 with OEM-unlocked 16kb kernel, Android 16, Xamarin Android SDK targeting .NET 8, Play Store upload accepted, app launches and runs correctly.