[prototype] Rewrite managed JNI names after R8 obfuscation - #12575
Draft
simonrozsival wants to merge 1 commit into
Draft
[prototype] Rewrite managed JNI names after R8 obfuscation#12575simonrozsival wants to merge 1 commit into
simonrozsival wants to merge 1 commit into
Conversation
Rebuild managed PE metadata and IL with obfuscated JNI class, method, field, descriptor, RegisterNatives, and FieldRVA string data. Preserve compression descriptor ordering when rewritten assembly sizes change. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Context
Fixes/advances #12535.
.NET for Android currently supplies
-dontobfuscateto R8. Java shrinking and optimization are enabled, but Java names are not shortened because managed bindings contain the original JNI names as data. Those names are used at runtime to find Java classes, methods, and fields and to register managed native callbacks. If R8 changes the Java names while the managed strings retain their original values, JNI lookup fails.This draft prototypes a build-time solution: let R8 choose the final Java names, consume its
mapping.txt, and rewrite the corresponding JNI names in the managed assemblies before they are packaged.The goal is to keep runtime JNI dispatch direct and allocation-free. The final application does not carry a mapping table and does not perform original-to-obfuscated name translation at runtime.
Initial assumptions and ideas
The investigation started with several possible designs:
Prescribe shortened names to R8. If the build generated stable short names itself, managed bindings and R8 could theoretically use the same names. R8's
-applymappinghelps reuse an existing mapping, but it does not preserve dynamically accessed members or make the build's independently generated names authoritative. R8 optimization and inlining also mean the output mapping is not always a simple one-to-one rename table. The prototype therefore treats R8's emitted mapping as the source of truth.Ship the mapping and translate names at runtime. This would work without rewriting existing binding assemblies, and resembles the broad shape of managed Android remapping support. It would, however, add application data, runtime lookup cost, startup work, and complexity to every JNI operation. Since Release build time is not the optimization target here, the preferred design moved toward paying the cost once during the build.
Run R8 first, then rewrite assemblies before ILLink/ILC and typemap generation. This looked attractive because every downstream system would naturally observe the obfuscated names. In the current build graph, however, R8 depends on Java sources and configuration derived from the linked managed application, while native application configuration and compressed-assembly descriptors are generated before R8. The prototype consequently runs after R8 and rewrites all managed artifacts that are ultimately packaged, including generated trimmable typemap assemblies. Production integration still needs to make this ordering explicit and incremental.
Patch string heaps in place. The first mental model was that most changes would be substitutions of string literals. In-place patching is insufficient: obfuscated values can be longer, one metadata/user-string heap value can be shared by use sites needing different owner-specific rewrites, custom-attribute blobs encode lengths, IL tokens may need new user-string handles, and UTF-8 data can live in FieldRVA-backed structures. The implementation evolved into a two-pass planner plus complete PE reconstruction using
System.Reflection.Metadata.Mono.Cecil is intentionally not used.
Feature design
The prototype consists of an R8 mapping model, a use-site-aware rewrite planner, and a managed PE rebuilder.
1. Parse R8 output
R8Mappingparses class, field, method, and constructor mappings and exposes JNI-oriented lookups.Real MAUI mappings changed the initial parser design in two important ways:
Class names are normalized to JNI slash-separated form while method parameter types retain the Java-source representation used by
mapping.txt.2. Plan exact use-site rewrites
JniRewritePlannerscans the complete assembly and records replacements by metadata handle or exact IL operand location rather than globally replacing string values.It currently handles:
RegisterAttributeJNI type and member namesJniPeerMembersencoded method and field identifiersRegisterNatives/ fast native-registration stringsJNIEnvlookup patterns where a referenced JNI class is followed by a member name and descriptorThe owner-sensitive plan matters because a shared string such as
run.()Vcan map to different Java names depending on the declaring JNI type. If a shared FieldRVA datum would require conflicting values, the rewriter fails instead of silently producing an invalid assembly.The direct
JNIEnvcase was discovered only after the first fully packaged app reached managed startup.Android.App.Application.Contextfindsnet/dot/android/ApplicationRegistration, then asks for the staticContextfield using separate string operands. Rewriting only attributes and encodedJniPeerMembersstrings left that field name unchanged and produced a realNoSuchFieldError. The planner now recognizes this class/member/descriptor pattern as well.3. Rebuild the PE
AssemblyRebuilderreconstructs the managed PE withSystem.Reflection.Metadata.Ecma335so replacements can have arbitrary lengths and distinct use sites can receive distinct values.The reconstruction preserves:
A rewritten strong-named assembly cannot retain its old signature. The prototype clears the signed flag while preserving the signature-directory reservation and reports that the assembly is left delay-signed. Authenticode data is omitted because any PE rewrite invalidates it; the enclosing APK is subsequently signed.
Assemblies with zero planned replacements are returned byte-for-byte unchanged. This optimization became a correctness requirement during device testing: rebuilding every framework assembly changed
System.Private.CoreLib.dllafter its expected decompression size had already been recorded. It also avoids stripping signatures and spending reconstruction time on the majority of assemblies.4. Keep compressed-assembly metadata consistent
Release applications record each assembly's uncompressed size and descriptor index in generated native data. Rewriting can change an assembly's size, so the native descriptors must be regenerated before
libxamarin-app.sois finalized.Simply regenerating descriptors from the rewritten item list was incorrect because item ordering differed from the original list used by the assembly store. That associated sizes with the wrong descriptor indices; for example, CoreLib received a 2 KiB typemap assembly's size.
GenerateCompressedAssembliesNativeSourceFilestherefore accepts optional size-source assemblies. It reuses the already registered descriptor layout and indices, substitutes only the rewritten file sizes by package key and ABI, and emits updated native data. The controlled experiment then recompiles and relinks the application native library.This is an important production integration constraint: the rewrite, compression-size regeneration, native recompilation, assembly compression, and packaging stages must agree on the same assembly identities and descriptor indices.
Current build flow demonstrated by the prototype
mapping.txt.RewriteJniNamesForR8scans and rewrites the packaged managed assemblies and generated typemap assemblies.The task and supporting implementation are included in this PR, but the experiment-only MSBuild hook is deliberately not. This draft does not remove the shipped
-dontobfuscaterule and does not yet enable the feature for product builds.MAUI device validation
The prototype was tested with a Release
dotnet new maui --sample-contentapplication using:android-arm64classes.dexclasses.dexBoth APKs were rebuilt against the same matched managed/native runtime artifacts.
The final obfuscated APK:
Tests
The focused suite contains 69 passing tests covering:
Xamarin.Android.Build.Tasksalso builds successfully.Open design and productization work
-dontobfuscateonly when the complete rewrite path is enabled.