1437 words
7 minutes
Bypassing MAUI Certificate SSL Pinning on Android

It all started with the new xamarin build - maui (.NET Multi-platform App UI).

When I faced an engagement which was a must for me to bypass SSL pinning, where unfortunately I failed. I was new dealing with MAUI. I did not know such framework existed, all I knew was the old granny xamarin.

Anyways, I took it as a challenge and started grinding thru docs and vids, until I kinda understood how things work.

The most important thing that was introduced in MAUI was AOT, ahead of time compile. You can check more about it here.

NOTE

AOT means your C# code is compiled to native machine code before the app even runs. No JIT compilation at runtime. The original xamarin bypass scripts assumed JIT was happening, which is why they broke.

Anyways, when it comes to xamarin SSL pinning bypass, there is no way we could not mention gosecure and alxbl for their hard work on their blog post and frida-xamarin-unpin.

Now after reading carefully I saw in the last part of the blog he mentioned that he did not make this work on AOT yet, full or partial, and they are still not working with .NET Framework applications which use ServicePointManager.

Soo here lights up my mind and says why not? Dude we are in 2026, lets fire up some agents and dig up.


The Plan#

So first I made some drafts of the plan and here is the conclusion:

  1. We needed to recreate the original xamarin SSL pinning bypass to make it work with frida 17 apis. This was a prerequisite to be done, since it had a lot of functions and methods that do not work anymore in frida 17.
  2. We needed some app that we know to test on. Luckily, alxbl already provided the src code of the xamarin sample app. Cloned it, rebuilt it using latest maui, changed the deprecated functions and methods, added some verbose logging, and compiled using JIT first then AOT second after getting JIT to work (at least we know that it should work) :“D
  3. We needed to understand why the original script crashes on MAUI using the JIT compile, and build a generic bypass that does not hardcode any app-specific class names.
  4. We needed to test it on an AOT compiled app and check its behaviour.
  5. Document everything and try to understand it “still trying :“D”.

Step 1 - Fixing Frida 17 Compatibility#

Before even touching maui, the original script would not even load on frida 17. It crashes immediately with:

TypeError: not a function

Root cause: frida 17.0.0 removed the static Module.findExportByName helpers. The frida-mono-api library was calling them at two spots.

Fix: two-line patch.

  • Module.findExportByName(monoModule.name, sym)monoModule.findExportByName(sym)
  • Module.findExportByName(null, sym)Module.findGlobalExportByName(sym)

I forked both repos and patched them:

Also opened upstream PRs back to GoSecure so the community can benefit:

Verified the patched script works on the original xamarin sample APK. Good, baseline established.


Step 2 - Building the MAUI Test Target#

I rebuilt alxbl’s sample app for .NET MAUI (net9.0-android). The app registers an HttpClient with a custom ServerCertificateCustomValidationCallback, exactly like the xamarin sample but using the unified BCL.

Compiled two versions:

  • JIT - RunAOTCompilation=false (debug / default)
  • AOT - RunAOTCompilation=true (release / optimized)

Both APKs are included in the repo if you want to test it: @ymuuuu/repo


Step 3 - The Crash#

Ran the patched xamarin script against the maui JIT app. It loads, hooks SendAsync, first request fires - then:

Error: access violation accessing 0x10
    at RuntimeInvoke (mono-api/src/mono-api-helper.js:71)
    at onEnter (src/main.js:55)
TIP

Why 0x10? The original script looks for a static factory method called CreateDefaultHandler() inside HttpClientHandler. On xamarin (mono BCL fork) this exists. On maui (unified BCL / dotnet/runtime) it does not. mono_class_get_method_from_name returns NULL. Passing NULL to mono_runtime_invoke dereferences offset +0x10 of the _MonoMethod struct - the signature field. The fault address tells you exactly which field was hit.

The fix was not to just guard the NULL. We needed a completely different approach for maui because the HttpClientHandler internals changed.

Find more here Mono Docs


The MAUI Approach - Four Techniques in One Script#

After a lot of trial and error (and enumerating fields at runtime to see what actually exists), the script now has four techniques depending on what it finds:

#TechniqueWhen it applies
1Modern path - CreateDefaultHandler() factory swapMono >= 6.0, xamarin classic
2MAUI path - mono_object_new + ctor + _handler swap + validator transfer + delegate introspectionunified BCL, JIT or AOT
3SocketsHttpHandler path - _settings._sslOptions walkUseNativeHttpHandler=false or non-maui .NET android apps
4Legacy path - ServicePointManager nullingMono < 6.0, best-effort

How the MAUI Path Works (The Juicy Part)#

When the script detects it is running on maui (no CreateDefaultHandler factory), it switches to the maui path:

1. Hook HttpMessageInvoker.SendAsync#

This is the entry point. Every HttpClient request goes through here.

2. Unwrap DelegatingHandler Wrappers#

Production apps love wrapping handlers:

LifetimeTrackingHttpMessageHandler -> LoggingScopeHttpMessageHandler -> ResilienceHandler -> SocketsHttpHandler

The script walks the _innerHandler chain, resolving the field on the runtime class of each wrapper (not the base class - that was a bug that read wrong memory offsets).

3. Detect the Real Handler#

  • If the unwrapped handler is SocketsHttpHandler → use technique #3
  • If it is HttpClientHandler / AndroidMessageHandler → use technique #2

4. The Swap + Transfer#

For HttpClientHandler:

  • Allocate a fresh HttpClientHandler via mono_object_new + parameterless ctor
  • Swap it into HttpMessageInvoker._handler
  • Transfer the old underlying handler’s _serverCertificateCustomValidator onto the fresh one

Why transfer? Without it, the fresh handler has a NULL validator, so SetupSSL() falls back to android system trust - which on android 7+ rejects user-installed CAs. With the transfer, SetupSSL() takes the custom-validator branch.

5. The Generic Hook - Stage 3 Delegate Introspection#

This is the part I am most proud of. Instead of hardcoding the app’s namespace + class + method name (like SampleApp.MauiProgram.ValidateCertificate), the script introspects the live validator instance at runtime (Let’s say that it is a semi auto detector):

validator instance
  -> <Callback>k__BackingField (the Func<...> delegate)
    -> MonoDelegate.layout +0x28 = MonoMethod* 
      -> mono_compile_method = native entry point
        -> Interceptor.attach onLeave -> retval.replace(ptr(1))

The MonoDelegate layout on ARM64 LP64 is stable across mono/mono and dotnet/runtime:

OffsetField
+0x00MonoObject header
+0x10method_ptr
+0x18invoke_impl
+0x20target
+0x28method (MonoMethod)*

So we read +0x28, compile the method, and hook it. No app-specific names anywhere in the script.

IMPORTANT

The hook is deduplicated via a _hookedMethods Set, so even if multiple HttpClient instances or lazy-init validators appear, each callback method is hooked exactly once.


The AOT Surprise#

Remember the blog said “iOS Full AOT not supported”? I tested against the AOT-compiled maui app expecting it to fail. It worked.

CAUTION

Plot twist: on android MonoVM AOT, mono_compile_method does not return a JIT stub. It returns the pre-compiled AOT native entry point. Interceptor.attach patches it directly, same as JIT. The Stage 3 hook successfully attached to ValidateCertificate (AOT-compiled), forced return=true, and the request returned OK.

This is an android-specific finding. iOS uses LLVM AOT with a different runtime layout - not addressed.

There was one small hiccup: a non-fatal access violation accessing 0x68 during script load. This was the ServicePointManager fallback block trying to load the System assembly (which does not exist in unified BCL) and dereferencing NULL. Fixed by guarding the entire legacy block behind if (!hooked).


SocketsHttpHandler Support#

Some apps set UseNativeHttpHandler=false, which makes the bottom handler SocketsHttpHandler instead of AndroidMessageHandler. Its certificate callback lives at:

SocketsHttpHandler._settings._sslOptions.<RemoteCertificateValidationCallback>k__BackingField

When the script detects SocketsHttpHandler, it walks this field chain and hooks the callback delegate using the same Stage 3 technique. If _sslOptions is null, the app has not configured a custom callback - the script logs this and returns cleanly.


DEBUG Flag#

Tired of noisy frida consoles? The script has a DEBUG flag at the top:

const DEBUG = true; // set to false for quiet mode
function dbg(...args) { if (DEBUG) console.log(...args); }

How to Use It#

TIP

Prerequisites: rooted android device, frida server running, MITM CA installed in the user trust store.

# 1. clone both repos
git clone https://github.com/ymuuuu/frida-mono-api-maui.git
git clone https://github.com/ymuuuu/frida-maui-unpin.git

# 2. build
cd frida-mono-api-maui && npm i
cd ../frida-maui-unpin && npm i && npm run build

# 3. launch the app, wait for mono init, then attach
frida -U -p $(adb shell "pidof -s com.test.sample.maui") -l ./dist/maui-unpin.js

# 4. trigger an HTTPS request in the app

Expected output with DEBUG = false:

[+] Hooked HttpMessageInvoker.SendAsync with mono_object_new technique (MAUI / unified BCL)
[+] Done!
Make sure you have a valid MITM CA installed on the device and have fun.
[+] Stage 3: hooked validator method (MonoMethod=0x..., name=ValidateCertificate, JIT=0x...), forced return=true

alt text


Repos#


Credits#

  • @freehuntx - original frida-mono-api and the xamarin bypass concept
  • GoSecure - maintained frida-xamarin-unpin and the extra branch of frida-mono-api
  • alxbl - original author of frida-xamarin-unpin

I am still learning along the way, most of the work was done by digging thru docs and using some high thinking AI Agents, mistakes are meant to happen, if you have any edit, suggestion or an adjustment, please hit me up.