#9644·ghidra

ELF loader drops PT_LOAD execute permission whenever a section header table is present (hardened libraries import with no executable memory)

Author: X-SCI-TECHCreated Sep 16, 2026Updated Sep 17, 2026
LabelsFeature: Loader/ELFStatus: Internal

Describe the bug

When an ELF file has a section header table (e_shnum != 0), ElfProgramBuilder.processProgramHeader() deliberately suppresses the execute bit of segment memory blocks:

// ElfProgramBuilder.java (master, 2026-09-16)
boolean maintainExecuteBit = elf.getSectionHeaderCount() == 0;            // line 3217

if (!maintainExecuteBit && elfProgramHeader.isExecute()) {
    comment += " (disabled execute bit)";                                 // line 3243
}
addInitializedMemorySection(..., 
    maintainExecuteBit ? elfProgramHeader.isExecute() : false, comment,   // line 3251
    ...);

Section blocks instead take their execute bit from sh_flags / SHF_EXECINSTR (line ~3499-3501). For well-formed files this is usually harmless, but if the section header table has been wiped or blanked (a common hardening / anti-analysis step for Android .so files, where only a few non-executable section headers survive), no section describes the code anymore, and consequently no memory block ends up executable — even though the enclosing PT_LOAD is PF_R|PF_X. The execute bit is dropped silently (only a " (disabled execute bit)" note ends up in the block comment).

Consequence: auto-analysis never disassembles the code (no instructions and no code functions in the affected range), and a user has to manually fix block permissions and re-run analysis. Loaders that derive permissions from program headers (IDA, radare2) load the same file normally. I hit this on a real-world hardened AArch64 library: PT_LOAD[0] is PF_R|PF_X over the first 0x4ca0 bytes, but after import every memory block came up non-executable and the code at the segment tail had zero instructions.

Note the inconsistency: with no section headers at all (e_shnum == 0) the very same code path honors PF_X (maintainExecuteBit == true), so a stale/partial section table is strictly worse than no section table at all.

Related: #5273 dealt with a neighboring case ("sections data may be incomplete or altered") and was addressed with an import option to preserve segment data. This looks like another instance of the same class.

To Reproduce

  1. Build a normal shared object (any small C file; a demo is provided below): gcc -shared -fPIC -o libdemo.so demo.c
  2. Run the reproducer script below to produce three sanitized copies:
    • libdemo_hmz.so — blank the section header entries of the executable sections (.init .plt .plt.got .plt.sec .text .finish_name/sh_address/sh_offset/sh_size = 0, sh_type/sh_flags kept). This mimics the hardened-library layout.
    • libdemo_noflags.so — zero all sh_flags.
    • libdemo_nosec.so — control: e_shoff=0, e_shnum=0, e_shstrndx=0.
  3. Import each with Ghidra 12.1.3 and inspect MemoryBlock.isExecute() (Memory Map, or a snippet like below). Observed results:
File Section table Executable blocks Result
libdemo.so intact .init/.plt/.plt.got/.plt.sec/.text/.fini = X OK
libdemo_nosec.so removed (e_shnum=0) segment_1 (0x1000-0x11a8) = X OK — PF_X honored
libdemo_hmz.so exec entries blanked none (segment_1 covering the RX segment = X=false) no executable memory
libdemo_noflags.so sh_flags zeroed none no executable memory
  1. In the libdemo_hmz.so case (import with default analysis), add() at 0x1060 is never disassembled — the symbol-derived function may exist, but no instructions are created because its memory block is not executable.
demo.c
#include <stddef.h>

int g_acc = 0;

int add(int a, int b) {
    g_acc += 1;
    return a + b + g_acc;
}

int mul(int a, int b) {
    int r = 0;
    for (int i = 0; i < b; i++) {
        r += add(a, i);
    }
    return r;
}

const char *msg(void) {
    return "demo";
}

int sub(int a, int b) {
    return add(a, -b);
}
mkvariants.py
#!/usr/bin/env python3
# Usage: gcc -shared -fPIC -o libdemo.so demo.c && python3 mkvariants.py libdemo.so
import struct, sys

src = sys.argv[1]
b = bytearray(open(src, 'rb').read())
assert b[:4] == b'\x7fELF' and b[4] == 2 and b[5] == 1, "ELF64 LE expected"

shoff, = struct.unpack_from('<Q', b, 0x28)
shentsize, shnum, shstrndx = struct.unpack_from('<HHH', b, 0x3a)
strtab, = struct.unpack_from('<Q', b, shoff + shstrndx * shentsize + 0x18)

def name_of(i):
    off, = struct.unpack_from('<I', b, shoff + i * shentsize)
    end = b.index(b'\0', strtab + off)
    return b[strtab + off:end].decode()

exec_secs = [i for i in range(shnum)
             if name_of(i) in ('.init', '.plt', '.plt.got', '.plt.sec', '.text', '.fini')]

# 1) blank the exec sections' header entries (name/addr/offset/size), keep type+flags
v = bytearray(b)
for i in exec_secs:
    o = shoff + i * shentsize
    struct.pack_into('<I', v, o, 0)                   # sh_name
    struct.pack_into('<QQQ', v, o + 0x10, 0, 0, 0)    # sh_addr, sh_offset, sh_size
open('libdemo_hmz.so', 'wb').write(v)

# 2) zero all sh_flags
v = bytearray(b)
for i in range(1, shnum):
    struct.pack_into('<Q', v, shoff + i * shentsize + 8, 0)
open('libdemo_noflags.so', 'wb').write(v)

# 3) control: remove the section header table entirely
v = bytearray(b)
struct.pack_into('<Q', v, 0x28, 0)       # e_shoff
struct.pack_into('<HH', v, 0x3c, 0, 0)   # e_shnum, e_shstrndx
open('libdemo_nosec.so', 'wb').write(v)
Block-flag check snippet (GhidraScript)
for (MemoryBlock mb : currentProgram.getMemory().getBlocks()) {
    println(mb.getName() + " X=" + mb.isExecute());
}

Expected behavior

Execute permission for loaded memory should be derived from PT_LOAD p_flags — or at minimum fall back to PF_X for segment ranges that are not covered by any SHF_EXECINSTR section — so that a sanitized or stale section header table cannot silently remove executable memory. An import option in the spirit of #5273 would also work. Happy to test a patch either way.

Environment

  • Ghidra 12.1.3 (official release; Windows, Java 23; reproduced on AArch64 and x86-64 ELFs)
  • Same logic present on master as of 2026-09-16 (ElfProgramBuilder.java lines 3217, 3238-3251)

Source: NationalSecurityAgency/ghidra