Notes From Reverse Engineering A Mono AOT Compiled App On iOS

Published ยท Last revised

To document the Microsoft Seeing AI API in my last blog post, I had to reverse engineer a Xamarin Mono full-AOT compiled app written for iOS, using a few of my favorite tools: mitmproxy, dnSpy, and Frida.

Here are the challenges I faced and how I overcame them.

Challenge: Application package (iOS App Store Package) is FairPlay DRM encrypted

I jailbroke an iPhone with the semi-tethered solution "DoubleH3lix" and installed OpenSSH and Clutch, an iOS executable/package dumper. This gave me access to the app binaries for static analysis.

Challenge: Application is fully aot-compiled down to armv7/arm64

I wasn't familiar with Apple's policies around JIT-compiled code, so this one caught me off-guard. I was expecting a bunch of managed Mono code and instead had nearly empty assemblies with some scaffolding and metadata. The rest was AOT compiled down to ARM instructions and stuffed into AOTData files.

Skimming strings, I discovered the web service endpoint but otherwise skipped static analysis and opt'ed for dynamic analysis instead.

Challenge: Application does not consume system proxy configuration

The application did not consume the proxy I configured on the phone. I suspect this is related to questionable Mono HttpClient or WebClient defaults.

So I altered /etc/hosts on the iPhone and redirected the web service endpoint to my PC. There, I used mitmproxy in the Windows Subsystem for Linux to configure a reverse proxy. (The Windows native version of mitmproxy is missing components and is difficult to work with.)

I ferried the mitmproxy CA root certificate to the phone via OneDrive; the mitm.it magic domain does not work in this configuration.

Challenge: Application signs all outgoing requests / web service validates all signatures

Observation of outbound traffic flowing through the reverse proxy revealed the application *signed *all outbound requests and transmitted the signature to the web service endpoint via an HTTP header. The signature was 32 bytes in length, prior to base-64 encoding.

Browsing the disassembled managed assemblies in the package (with dnSpy), I found a SignatureHelper class definition (no implementation) with a static readonly byte[] Secret field. This strongly suggested a keyed hash algorithm was in play.

To get the secret, I wrote a Frida agent (consuming the frida-mono-api module) to access the static field and dump its value. (I used frida-compile to transpile the agent code to a Frida-compatible ECMAScript 6. I also had to fill some gaps in the frida-mono-api module.)

The Frida agent script I wrote and injected into the app:

import { MonoApiHelper, MonoApi } from 'frida-mono-api'
const domain = MonoApi.mono_get_root_domain()

// Get a handle to the SeeingAI.Core assembly
let coreAssembly = MonoApi.mono_assembly_load_with_partial_name(Memory.allocUtf8String("SeeingAI.Core"), NULL)
let coreImage = MonoApi.mono_assembly_get_image(coreAssembly)

// Retrieve class metadata
let helperClass = MonoApiHelper.ClassFromName(coreImage, "SeeingAI.Network.SignatureHelper")

// Retrieve field metadata and value
let secretField = MonoApiHelper.ClassGetFieldFromName(helperClass, "Secret")
let secretValue = MonoApiHelper.FieldGetValueObject(secretField, NULL, domain)

// Dump array to screen
var secretValueLength = MonoApi.mono_array_length(secretValue)
console.log(hexdump(MonoApi.mono_array_addr_with_size(secretValue, 1, 0), { length: secretValueLength }))

With the 30-byte secret in hand, I built a quick C# app around HMACSHA256, as it fit the constraints perfectly (e.g. keyed input, 32-byte output). I passed in the request body as input, my secret and ... it produced the wrong hash.

I revisited the network analysis and recognized despite varying inputs, the signature remained the same. So my request body-based input was provably wrong. I had to get the real input.

I made modifications to the earlier agent script to intercept the input argument of the AOT-compiled method:

import { MonoApiHelper, MonoApi } from 'frida-mono-api'
const domain = MonoApi.mono_get_root_domain()

// Get a handle to the SeeingAI.Core assembly
let coreAssembly = MonoApi.mono_assembly_load_with_partial_name(Memory.allocUtf8String("SeeingAI.Core"), NULL)
let coreImage = MonoApi.mono_assembly_get_image(coreAssembly)

// Retrieve class metadata
let helperClass = MonoApiHelper.ClassFromName(coreImage, "SeeingAI.Network.SignatureHelper")

// Get pointer to AOT compiled method
let methodInfo = MonoApiHelper.ClassGetMethodFromName(helperClass, "GenerateSignature", 1)
let monoError = Memory.alloc(32) // Allocate enough memory for MonoError initialization
let nativeMethodPtr = MonoApi.mono_aot_get_method(domain, methodInfo, monoError)

// Attach interceptor and fish out the first method argument
Interceptor.attach(nativeMethodPtr, {
onEnter: function(args) {
console.log("GenerateSignature called")
console.log("args[1] => " + MonoApiHelper.StringToUtf8(args[1]))
}
})

console.log("Interceptor attached and ready.")

As suspected, the input was not the request body but rather ... the query component of the endpoint URI. (It's not clear what value this signature scheme adds to the overall solution.)

The formula was completed and produced valid signatures: base64(HMACSHA256(uri-query-component)).