Skip to content

Remove the traces our own unloading leaves behind - #161

Merged
JingMatrix merged 1 commit into
masterfrom
linker-unload-trace
Sep 3, 2026
Merged

JingMatrix merged 1 commit into
masterfrom
linker-unload-trace

Conversation

@JingMatrix

@JingMatrix JingMatrix commented Sep 3, 2026

Copy link
Copy Markdown
Owner

clean_linker_trace() in run_modules_post asked for jit-cache-zygisk. The daemon creates the module memfd as zygisk-module, and has done since 5e43a06 renamed it back from the former; b4d31ab caught the same stale name in spoof_virtual_maps and fixed that call site, but this one was never opened. So for every process where a module declines to unload — 25 of them in one boot here, roughly one in six, system_server among them — the linker kept an soinfo record whose realpath reads /memfd:zygisk-module (deleted). spoof_virtual_maps hides the mapping, so nothing shows in /proc/<pid>/maps; the record is visible to anyone who walks the linker's list, which is exactly what Demo's solist scan does.

Releasing an soinfo hands its block back to the linker's slab allocator, which is strictly LIFO: free() pushes at the head and alloc() pops it, so the block released last is reissued first. Releasing our records front to back therefore hands them back to the next loads in reverse, and dl_iterate_phdr enumerates in solist order, which is allocation order — an object landing on a lower block than the one before it is a step backwards that a process which never unloaded anything cannot show. Release highest block first, in dropSoPath and in the module loop, and the reuse stays ascending. Measured: the step is gone, three runs.

dropSoPath collects before releasing now. soinfo_free memsets the block, and soinfo::next lives past the free-list pointers threaded through its first sixteen bytes, so walking the list while releasing read a zeroed next and stopped after the first hit — with several matching records only one ever went.

The self-unmap was short by the .bss. The ptracer measures us by summing the mappings that carry our pathname, and bionic maps the tail of the last PT_LOAD as an anonymous zeromap with no file behind it (ElfReader::LoadSegments): 8192 bytes on arm64, one page on the 32-bit ABIs, stranded in every process we inject into. dropSoPath now reports the reservation the linker recorded in soinfo::size, and hook_entry prefers it once its base agrees with the range the ptracer handed over.

That sum also keyed on a pathname substring. It identifies the library by name once and collects by device and inode after, so a (deleted) suffix or a second mapping sharing the basename cannot widen the range we later unmap.

Carry the whole .llvm suffix. Android 17 ships a twenty-digit hash, twenty-six characters with the prefix, which exactly filled the old buffer and left it without a terminator for snprintf to run off; solinker then failed to resolve and the solist path degraded silently.

Spoofed regions are named dalvik-DEX data rather than left anonymous. Note this only defeats a scan that reads the label — PR_SET_VMA_ANON_NAME lets any process name its own anonymous memory, so the name is not evidence either way.

@JingMatrix JingMatrix changed the title Leave the linker's module counters alone and unmap our full reservation Remove the traces our own unloading leaves behind Sep 3, 2026
clean_linker_trace() in run_modules_post asked for "jit-cache-zygisk". The daemon
creates the module memfd as "zygisk-module", and has since 5e43a06 renamed it back
from the former; b4d31ab caught the same stale name in spoof_virtual_maps but never
opened this call site. So wherever a module declines to unload -- 25 processes in one
boot here, system_server among them -- the linker kept an soinfo record reading
/memfd:zygisk-module (deleted). spoof_virtual_maps hides the mapping; the record
stays visible to anyone walking the list.

Releasing an soinfo returns its block to the linker's slab allocator, which is
strictly LIFO, so the block released last is reissued first. Releasing front to back
hands them back in reverse, and dl_iterate_phdr enumerates in allocation order -- an
object on a lower block than the one before it is a step backwards that a process
which never unloaded anything cannot show. Release the highest block first, in
dropSoPath and in the module loop, and the reuse stays ascending. Collect before
releasing too: soinfo_free memsets the block, so walking the list while releasing
read a zeroed next and stopped after the first hit.

The self-unmap was short by the .bss. The ptracer sums the mappings carrying our
pathname, and bionic maps the tail of the last PT_LOAD as an anonymous zeromap with
no file behind it -- 8192 bytes on arm64, one page on the 32-bit ABIs, stranded in
every process we inject into. dropSoPath now reports soinfo::size, and hook_entry
prefers it once the base agrees. That sum identifies the library by name once and
collects by device and inode after, so a "(deleted)" suffix cannot widen the range we
unmap.

Carry the whole .llvm suffix: Android 17's twenty-six characters filled the old
buffer and left it unterminated, so solinker failed to resolve and the solist path
degraded silently. Spoofed regions are named rather than left anonymous.
@JingMatrix
JingMatrix merged commit 225b32b into master Sep 3, 2026
1 check passed
HSSkyBoy pushed a commit to HSSkyBoy/NyaZygisk that referenced this pull request Sep 6, 2026
clean_linker_trace() asked for a memfd name the daemon no longer creates, so wherever a
module declines to unload the linker kept an soinfo record naming a deleted memfd.
spoof_virtual_maps conceals the mapping, not the record, and the record is what a walk of
the linker's list returns.

An soinfo returns to a strictly LIFO slab allocator, so the block released last is
reissued first. Releasing our records front to back hands them back in reverse, while
dl_iterate_phdr enumerates in allocation order -- a later object on a lower block is a
step backwards that a process which never unloaded anything cannot produce. Releasing the
highest block first keeps the reuse ascending. Collecting before releasing is required
regardless: soinfo_free zeroes the block, so a walk that releases as it goes reads a
zeroed next and stops after the first match.

The self-unmap measured us by summing mappings that carry our pathname, but the tail of
the last PT_LOAD is an anonymous zeromap with no path, so it was excluded and left
mapped. soinfo::size is the reservation the linker actually recorded.

Also: match the injected library by device and inode, carry the whole .llvm symbol
suffix, and name the spoofed regions.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant