Reversing a Modified AES in a Mobile App - Reconstructing Bangcle laes with Frida Stalker

About the author. I’m Colin Xiao — an independent reverse engineer, mostly working solo on contract jobs. I dig into compiled code of every kind, including but not limited to: Android and iOS apps, x86/x64/ARM native binaries, packers and obfuscators, DRM and license checks, network protocols and APIs, crypto and signature algorithms, malware, game binaries, and embedded/IoT firmware. I also handle large-scale data collection and vulnerability research. If you have any project in mind, I’d be glad to take it on — whatever it is, as long as it’s within my wheelhouse. Just reach out. You can reach me on X at @colinxiao_lab or on Telegram at https://t.me/ColinXiao666.

This research is published for educational and defensive purposes. The target sample is anonymized throughout.

I’ve spent my mobile-security career on the Android side, and iOS kept getting deferred — until a few weeks ago, when I got to pick the brain of a senior iOS reverse engineer and finally crossed the threshold. This article is that experience, written from an Android reverse engineer’s point of view, and it ends with the real payoff: fully reconstructing a modified AES algorithm shipped by the Bangcle hardening tool.

Getting set up

Device and tools

On Android I reverse on a Pixel/Nexus with Magisk. For iOS, pick a jailbreakable device — an older iPhone 8 (or 6/X/SE) on iOS 13–14 is ideal; sellers on the second-hand market often pre-jailbreak it with unc0ver. On the device you’ll want, from Cydia: Apple File Conduit “2”, AppSync Unified, Filza, SSL Kill Switch 2, OpenSSH, and frida (install the .deb from frida’s releases). On the desktop: ifunbox, frida, frida-ios-dump, class-dump, and iproxy (brew install usbmuxd).

frida on device

If a tweak breaks boot, ssh in (ssh root@127.0.0.1 -p 2222, default password alpine), inspect /Library/MobileSubstrate/DynamicLibraries, rename the suspect, and restart SpringBoard.

tweak list

Starting the analysis

Dump, class-dump, capture

App Store apps are encrypted, so dump first: iproxy 2222 22, then ./dump.py <bundle id>.

./dump.py com.xxx
Dumping xxx to /var/folders/rl/6nvyvpmj3z352q0m8xvm0db40000gn/T
[frida-ios-dump]: ZmFFmpeg.framework has been loaded.
[frida-ios-dump]: libswift_Concurrency.dylib has been dlopen.
...
libswift_Concurrency.dylib.fid: 100%|█████████████████████████████| 408k/408k [00:00<00:00, 5.97MB/s]
Validated.plist: 251MB [00:14, 18.2MB/s]
0.00B [00:00, ?B/s]Generating "xxx.ipa"

Rename the ipa to .zip, unzip, and the Payload/*.app bundle holds the main binary (anonymized here as TargetApp):

app bundle

drag into IDA

Export headers with class-dump -H ./TargetApp.app/TargetApp -o ./TargetAppH, then capture traffic with Charles and look at the login endpoint.

Capture shows an encrypted body

The body is encrypted — let’s find out how.

Locating the crypto

Keyword search leads nowhere useful

Search IDA for /api/account/prod/sign_in. It resolves, and the cross-references all call sub_1063DF0A8:

Oddly, the second argument (the URL string) doesn’t show up in F5 pseudocode:

But it’s clearly there in the assembly — possibly an IDA decompiler quirk:

Backtracing from there didn’t land me on the algorithm, so I switched approaches.

Hook base64

The body looks like base64, so hook NSData’s base64 method with frida-trace:

frida-trace -UF -m "-[NSData base64EncodedStringWithOptions:]"

This drops an auto-generated handler in ./__handlers__/. Edit it:

{
  onEnter(log, args, state) {
    this.self = args[0];
  },
  onLeave(log, retval, state) {
    var before = ObjC.classes.NSString.alloc().initWithData_encoding_(this.self, 4);
    var after = new ObjC.Object(retval);
    log(`-[NSData base64EncodedStringWithOptions:]before=${before}=`);
    log(`-[NSData base64EncodedStringWithOptions:]after=${after}=`);
    if(after.toString().indexOf("sEn8t")>=0){
      console.log(Thread.backtrace(this.context, Backtracer.ACCURATE) .map(DebugSymbol.fromAddress).join('\n'));
    }
  }
}

Luckily the return value matches the captured body, and the backtrace points straight at it:

0x102e0a7d8 TargetApp!+[ZHWhiteBoxEncryptTool encryptDataBase64String:]
0x102e0a6b8 TargetApp!+[ZHWhiteBoxEncryptTool encryptData:]
0x10444b098 TargetApp!+[NSURLRequest zh_whiteBoxEncryptRegisterLoginURLHTTPBody:]
0x10447c3f0 TargetApp!+[ZHURLProtocol canonicalRequestForRequest:]
0x1a1863ffc CFNetwork!0x3ffc (0x180a47ffc)
...

Inside encryptDataBase64String:, the key is a hardcoded 360-hex-digit string (180 bytes once hex-decoded):

Three calls make up the chain:

void *__fastcall sub_106B3E2A0(void *a1, void *a2, void *a3, void *a4, void *a5)
{
  return _objc_msgSend(a1, "preDataIn160:secureKey:iv:", a3, a4, a5);
}
void *__fastcall sub_106B1CA20(void *a1, void *a2, void *a3, void *a4, void *a5)
{
  return _objc_msgSend(a1, "laesEncryptData:secureKey:iv:", a3, a4, a5);
}
void *__fastcall sub_106B3E2E0(void *a1, void *a2, void *a3, void *a4, void *a15)
{
  return _objc_msgSend(a1, "preDataOut160:secureKey:iv:", a3, a4, a15);
}

The class-dump headers confirm it’s Bangcle:

Hook the three functions. One iOS gotcha: even though you still use base+offset to hook, you can’t just hexdump ObjC arguments like on Android. Wrap them as ObjC objects first — if you get something like {length = 32, bytes = 0x36666161 ...}, then Memory.readByteArray(data.bytes(), data.length()) to dump them.

id __cdecl +[BangcleCryptoTool laesEncryptData:secureKey:iv:](BangcleCryptoTool_meta *self, SEL a2, id a3, id a4, id a5)

Active invocation

To test the algorithm in isolation, call it directly. laesEncryptData calls sub_1000902A8, which calls the real AES sub_100090420:

__int64 __fastcall sub_1000902A8(__int64 a1, unsigned int a2, __int64 a3, __int64 a4, __int64 a5, unsigned int a6, __int64 a7, unsigned int a8, int a9)
{
  v24 = a1;
  v23 = a2;
  v22 = a3;
  v21 = a4;
  v20 = a5;
  v19 = a6;
  a7a = a7;
  a8a = a8;
  v16 = a9;
  LODWORD(v10) = 1;
  HIDWORD(v10) = 4;
  v14 = 1;
  v15 = a9;
  v12 = 1;
  v13 = 0;
  v11 = 0;
  return sub_100090420(a1, a2, a3, (int *)a4, a5, a6, a7, a8, &v10);
}

Two iOS-specific details. First, the address offset: when hooking via base+offset, subtract 0x10000000 from IDA addresses. Second — a real trap — the pseudocode above is misleading: this function has 9 arguments, and on ARM64 anything past the 8th goes on the stack, so the last parameter v10 isn’t passed as the decompiler suggests:

__text:00000001000902A8 sub_1000902A8
__text:00000001000902A8                 STP             X29, X30, [SP,#-0x10]!
__text:00000001000902AC                 MOV             X29, SP
__text:00000001000902B0                 SUB             SP, SP, #0x70
__text:00000001000902B4                 LDR             W8, [X29,#0x10]
__text:00000001000902B8                 ADD             X9, SP, #0x10 ; X9 holds the stack arg
__text:00000001000902BC                 MOV             W10, #1
__text:00000001000902C0                 MOV             W11, #4
__text:00000001000902C4                 STUR            X0, [X29,#-8]
__text:00000001000902C8                 STUR            W1, [X29,#-0xC]
__text:00000001000902CC                 STUR            X2, [X29,#-0x18]
__text:00000001000902D0                 STUR            X3, [X29,#-0x20]
__text:00000001000902D4                 STUR            X4, [X29,#-0x28]
__text:00000001000902D8                 STUR            W5, [X29,#-0x2C]
__text:00000001000902DC                 STR             X6, [SP,#0x38]
__text:00000001000902E0                 STR             W7, [SP,#0x34]
__text:00000001000902E4                 STR             W8, [SP,#0x30]
__text:00000001000902E8                 STR             W10, [SP,#0x10]
__text:00000001000902EC                 STR             W11, [SP,#0x14]
__text:00000001000902F0                 STR             W10, [SP,#0x28]
__text:00000001000902F4                 LDR             W8, [SP,#0x30]
__text:00000001000902F8                 STR             W8, [SP,#0x2C]
__text:00000001000902FC                 STR             W10, [SP,#0x20]
__text:0000000100090300                 STR             WZR, [SP,#0x24]
__text:0000000100090304                 STR             WZR, [SP,#0x18]
__text:0000000100090308                 LDUR            X0, [X29,#-8]
__text:000000010009030C                 LDUR            W1, [X29,#-0xC]
__text:0000000100090310                 LDUR            X2, [X29,#-0x18]
__text:0000000100090314                 LDUR            X3, [X29,#-0x20]
__text:0000000100090318                 LDUR            X4, [X29,#-0x28]
__text:000000010009031C                 LDUR            W5, [X29,#-0x2C]
__text:0000000100090320                 LDR             X6, [SP,#0x38]
__text:0000000100090324                 LDR             W7, [SP,#0x34]
__text:0000000100090328                 STR             X9, [SP]
__text:000000010009032C                 BL              sub_100090420
__text:0000000100090330                 MOV             SP, X29
__text:0000000100090334                 LDP             X29, X30, [SP+var_s0],#0x10
__text:0000000100090338                 RET

Cross-referencing a9 inside sub_100090420 shows it’s an int array, using up to v20[7]:

Combined with the frida hook output:

a9 is an int array of 8 elements (32 bytes). Construct it accordingly:

var dword = Memory.alloc(32);
Memory.writeUInt(dword,1);
Memory.writeUInt(dword.add(4),4);
Memory.writeUInt(dword.add(4*2),0);
Memory.writeUInt(dword.add(4*3),1);
Memory.writeUInt(dword.add(4*4),1);
Memory.writeUInt(dword.add(4*5),0);
Memory.writeUInt(dword.add(4*6),1);
Memory.writeUInt(dword.add(4*7),1);

The active call. sub_100090420’s first 8 parameters are: input/length, output/length, iv/length, key/length, with real values captured from earlier hooks:

function call_aes(){
    var base = Module.getBaseAddress("TargetApp");
    console.log("base: ",base);
    var aes = new NativeFunction(base.add(0x90420),'int',['pointer','int','pointer','pointer','pointer','int','pointer','int','pointer']);
    // input
    var data_len = 0x20;
    const data = Memory.alloc(data_len);
    Memory.writeByteArray(data,[0xca,0xcc,0x6e,0x68,0x64,0x63,0xc6,0x6e,0x60,0xc2,0x66,0xc4,0xc8,0x6c,0xc4,0xc6,0xca,0xc2,0x60,0xc4,0x6c,0x64,0x61,0x61,0x61,0xc4,0xc6,0xc4,0xc2,0xc8,0x6c,0x62]);

    // output
    var result_len = 16 * (data_len / 16 + 1);
    var result = Memory.alloc(result_len);
    var result_len_ptr = Memory.alloc(Process.pointerSize);
    result_len_ptr.writeUInt(result_len);

    // iv
    var iv_len = 0x10;
    const iv =  Memory.alloc(iv_len);
    Memory.writeByteArray(iv,[0x4c,0x41,0xb2,0xc9,0xb4,0xba,0xff,0x8a,0x6a,0x69,0xa5,0x99,0x02,0x5f,0x03,0x15]);

    // key (180 bytes)
    var key_len = 0xb4;
    const key =  Memory.alloc(key_len);
    Memory.writeByteArray(key,[0x93,0x02,0x01,0x9f,0xbf,0xa1,0xbb,0x6b,0xdb,0x9f,0xca,0x46,0x84,0xb3,0xe7,0xf6,0x38,0x30,0x44,0x18,0x14,0x06,0x35,0x60,0x29,0x7e,0x4f,0x00,0xde,0x63,0x69,0x41,0x66,0x4f,0x7e,0xa3,0x94,0x29,0xb2,0x60,0x4e,0x4f,0x93,0xa7,0x84,0x0e,0xcf,0x12,0x54,0xcb,0xa8,0xd9,0xea,0x29,0xcd,0xf4,0xf7,0xe4,0x01,0x97,0xb5,0x0d,0xf7,0x7e,0x19,0xfb,0x07,0xf2,0xf9,0x74,0xe7,0x87,0xcf,0x87,0x32,0xa6,0x2a,0x1e,0x2e,0x0f,0xcb,0xfa,0x2a,0xcb,0xac,0x63,0x76,0xc8,0x32,0xc0,0x82,0x39,0xa0,0xb5,0xd9,0xe0,0xe7,0x06,0xeb,0x27,0xb8,0x31,0xe5,0xef,0xfc,0xdb,0x3d,0x00,0x08,0x7e,0x62,0xa6,0x02,0x92,0x31,0xf6,0x4a,0x2b,0x30,0x99,0x72,0x07,0x59,0xe3,0x1f,0x9d,0xfa,0x12,0x8b,0xc7,0xe9,0x6a,0x83,0xd7,0x1a,0xf7,0x9a,0xa4,0x89,0xb9,0xe5,0x6f,0xfd,0xd5,0xe2,0xf1,0x42,0xa3,0xf9,0xac,0x11,0xe4,0xab,0xce,0x01,0xc6,0xf2,0xfb,0xca,0x01,0xb7,0x59,0xac,0x84,0x2f,0x14,0x91,0xa1,0xa5,0x8d,0x74,0xea,0xdd,0x2b,0x38,0x09,0x1e,0xb8,0x21,0x16])
    // last argument
    var dword = Memory.alloc(32);
    Memory.writeUInt(dword,1);
    Memory.writeUInt(dword.add(4),4);
    Memory.writeUInt(dword.add(4*2),0);
    Memory.writeUInt(dword.add(4*3),1);
    Memory.writeUInt(dword.add(4*4),1);
    Memory.writeUInt(dword.add(4*5),0);
    Memory.writeUInt(dword.add(4*6),1);
    Memory.writeUInt(dword.add(4*7),1);
    // call
    var aes_r = aes(data,data_len,result,result_len_ptr,iv,iv_len,key,key_len,dword);
    console.log("aes_r",aes_r,hexdump(result,{length:result_len}));
}

Active invocation works. Now for the algorithm itself.

The core algorithm

Standard AES, briefly

AES-128 takes 16 plaintext bytes and a 16-byte key, outputting 16 ciphertext bytes. Each +64 bits of key adds 2 rounds. AES-128 runs 10 rounds of:

  1. SubBytes — byte substitution via the S-box.
  2. ShiftRows — row 1 unchanged, row 2 left by 1, row 3 by 2, row 4 by 3.
  3. MixColumns — multiply by a fixed matrix.
  4. AddRoundKey — XOR with the round key.

The initial transform is AddRoundKey only; rounds 1–9 run all four steps; the final round omits MixColumns.

Tracing sub_100090420

sub_100090420 is already out of ObjC and into plain C — no obfuscation, but branch-heavy:

I used Virenz’s Stalker scripts (function trace + instruction trace). The function trace shows surprisingly few callees:

function trace:
 [0x100091054]( [0x10009047c] ) -- 0 //1. key-schedule post-processing
  [0x106a9b340]( [0x100091074] ) -- 1 	//malloc
 [0x100091b3c]( [0x100090520] ) -- 0
 [0x100091bcc]( [0x100090548] ) -- 0
 [0x106a9a530]( [0x100090614] ) -- 0	//calloc
 [0x106a9a1e8]( [0x10009062c] ) -- 0	//memcpy
 [0x100091c7c]( [0x100090664] ) -- 0	//pkcs-like padding
  [0x106a9a1f4]( [0x100091cf8] ) -- 1 	//memset
  [0x106a9a1f4]( [0x100091d6c] ) -- 1		//memset
 [0x100094360]( [0x100090b18] ) -- 0	//2. CBC, plaintext XOR
  [0x100091fac]( [0x10009440c] ) -- 1 //3. the real modified AES; 32-byte input pads to 48, 48/16 = 3, so 0x100091fac loops 3x
  [0x100091fac]( [0x10009440c] ) -- 1
  [0x100091fac]( [0x10009440c] ) -- 1
 [0x106a9aaa0]( [0x100091038] ) -- 0	//free
 [0x100091ef4]( [0x100091040] ) -- 0
  [0x106a9aaa0]( [0x100091f18] ) -- 1		//free

Look at sub_100094360:

__int64 __fastcall sub_100094360(__int64 a1, __int64 a2, int a3, __int64 a4, __int64 a5, void (__fastcall *a6)(__int64, __int64, __int64, unsigned int *))
{
  __int64 iv_1; // [xsp+0h] [xbp-40h]
  signed int i; // [xsp+8h] [xbp-38h]
  unsigned int v9; // [xsp+Ch] [xbp-34h]
  void (__fastcall *v10)(__int64, __int64, __int64); // [xsp+10h] [xbp-30h]
  __int64 keyptr; // [xsp+18h] [xbp-28h]
  __int64 iv; // [xsp+20h] [xbp-20h]
  int result_len; // [xsp+2Ch] [xbp-14h]
  __int64 result; // [xsp+30h] [xbp-10h]
  __int64 data; // [xsp+38h] [xbp-8h]

  data = a1;
  result = a2;
  result_len = a3;
  iv = a4;
  keyptr = a5;
  v10 = a6;
  v9 = 0;
  iv_1 = a4;
  while ( result_len >= 16 )
  {
    for ( i = 0; i < 16; ++i )
      *(result + i) = *(data + i) ^ *(iv_1 + i);
    (v10)(result, result, keyptr, &v9); // the AES block
    iv_1 = result;
    result_len -= 16;
    data += 16LL;
    result += 16LL;
  }
  return v9;
}

This is textbook CBC: each 16-byte plaintext block is XORed with the previous ciphertext block (or the IV for the first), then passed to the block cipher sub_100091FAC, whose output becomes the next IV.

Reconstructing the modified AES

sub_100091FAC is the core (first two args are the same buffer — plaintext in, ciphertext out; the third is the key, notably not 16 bytes):

__int64 __fastcall sub_100091FAC(__int64 result, __int64 a2, __int64 *key2)
{
  // ... local variables v3..v59 omitted for brevity ...
  key2_1 = *key2;
  v3 = *(key2 + 4) + (*(key2 + 4) < 0 ? 0x1F : 0);
  for ( i = 0; i < 16; ++i )
    *(&v44 + i) = (byte_106EF9068[*(key2_1 + i) & 0xF ^ 16 * (*(result + i) & 0xF)] >> 4) & 0xF ^ 16
                                                                                                * ((byte_106EF9068[(*(key2_1 + i) >> 4) & 0xF ^ 16 * ((*(result + i) >> 4) & 0xF)] >> 4) & 0xF);
  for ( j = 1; j < (v3 >> 5) + 6; ++j )
  {
    v28 = dword_106EF9168[v44] >> 24;
    v29 = dword_106EF9168[v44] >> 16;
    v30 = LOWORD(dword_106EF9168[v44]) >> 8;
    v31 = dword_106EF9168[v44];
    v32 = dword_106EF9168[v48] >> 24;
    // ... four groups of table lookups ...
    for ( k = 0; k < 16; ++k )
      *(&v28 + k) = (byte_106EF9968[*(&v12 + k) & 0xF ^ 16 * (*(&v28 + k) & 0xF)] >> 4) & 0xF ^ 16
                                                                                              * ((byte_106EF9968[(*(&v12 + k) >> 4) & 0xF ^ 16 * ((*(&v28 + k) >> 4) & 0xF)] >> 4) & 0xF);
    // ... three more 16-byte SubBytes-style passes ...
    for ( n = 0; n < 16; ++n )
      *(&v44 + n) = (byte_106EF9968[*(key2_1 + n + 16 * j) & 0xF ^ 16 * (*(&v28 + n) & 0xF)] >> 4) & 0xF ^ 16 * ((byte_106EF9968[(*(key2_1 + n + 16 * j) >> 4) & 0xF ^ 16 * ((*(&v28 + n) >> 4) & 0xF)] >> 4) & 0xF);
  }
  v28 = byte_106EFA268[v44];
  v29 = byte_106EFA268[v49];
  // ... final S-box pass ...
  for ( ii = 0; ii < 16; ++ii )
    *(a2 + ii) = (byte_106EFA368[*(key2_1 + ii + 16 * j) & 0xF ^ 16 * (*(&v28 + ii) & 0xF)] >> 4) & 0xF ^ 16 * ((byte_106EFA368[(*(key2_1 + ii + 16 * j) >> 4) & 0xF ^ 16 * ((*(&v28 + ii) >> 4) & 0xF)] >> 4) & 0xF);
  return result;
}

All along I was wondering: how far did Bangcle actually mutate AES? It’s branded laes, the outer layer is clearly CBC, and the trace shows a 9-round loop — but did it just swap the S-box, or rewrite the cipher internals? And why is the key 180 bytes instead of 16?

First loop

key2_1 = *key2;
v3 = *(key2 + 4) + (*(key2 + 4) < 0 ? 0x1F : 0); // (1)
for ( i = 0; i < 16; ++i ) // (2)
  *(&v44 + i) = (byte_106EF9068[*(key2_1 + i) & 0xF ^ 16 * (*(result + i) & 0xF)] >> 4) & 0xF ^ 16 * ((byte_106EF9068[(*(key2_1 + i) >> 4) & 0xF ^ 16 * ((*(result + i) >> 4) & 0xF)] >> 4) & 0xF);

I reconstructed this by copying the pseudocode into CLion and running it with the same inputs as the trace. (1) returns 0x80, confirmed against the trace.

For (2), I split IDA’s one-liner and malloc’d my own buffer for the v44 result:

Two ways to check correctness: the classic way — find the assembly address and compare register values against the trace; or, since Virenz’s Stalker script lets you customize output (hexdump instead of readCString()):

Search the trace for the loop result 4c da e9 c4 5a a1 0f 28 1e a2 01 ed 5b b6 62 b9 — it appears in many places, so the first loop is reconstructed correctly.

The 9-round mixing

Next is the big loop for ( j = 1; j < (v3 >> 5) + 6; ++j ). With v3 = 0x80, that’s (0x80 >> 5) + 6 = 10 — i.e., AES’s 9 rounds (plus the initial/final transforms).

The pseudocode here is tidy and regular, in groups of four:

But it raised a confusing question: both inputs and outputs are locals. v28 = dword_106EF9168[v44] >> 24; is clear enough (v44 is the previous result), but what’s v48 in the next group? I guessed v44 is a char array, so v48 = v44[1] = 0xda — but the trace disagreed:

// trace
[0x100092188]	0x104c42188	ldurb w10, [x29, #-0x14];     	# x10: 0x6d --> 0x5a,
[0x10009218c]	0x104c4218c	mov x11, x10;                 	# x11: 0x10baa9298 --> 0x5a,
[0x100092190]	0x104c42190	orr x12, xzr, #4;             	#
[0x100092194]	0x104c42194	mul x11, x11, x12;            	# x11: 0x5a --> 0x168,
[0x100092198]	0x104c42198	add x11, x9, x11;             	# x11: 0x168 --> 0x10baa92d0 ( // table address
10baa92d0  af 37 37 2d fa 73 73 d2 3e c7 c7 48 d3 a4 a4 0c  .77-.ss.>..H....
10baa92e0  ff 77 77 dd 59 51 51 5e d1 8b 8b 4d 10 52 52 16  .ww.YQQ^...M.RR.),
[0x10009219c]	0x104c421bc	ldr w10, [x11];               	# x10: 0x5a --> 0x2d3737af, // = dword_106EF9168[v48]
[0x1000921a0]	0x104c421a0	lsr w10, w10, #0x18;          	# x10: 0x2d3737af --> 0x2d,
...
[0x1000921c0]	0x104c421c0	lsr w10, w10, #0x10;          	# x10: 0x2d3737af --> 0x2d37,
[0x1000921c4]	0x104c421c4	and w10, w10, #0xff;          	# x10: 0x2d37 --> 0x37,

The values trace back to the table entry dword_106EF9168[v48], so the question is where v48 = 0x5a came from. It’s loaded from [x29, #-0x14], but nothing in the trace writes there — dead end.

New hypothesis: what if v44 isn’t a char pointer but an int pointer, so v48 = *(v44+1) reads 4 bytes later? Full v44 is 4c da e9 c4 5a a1 0f 28 1e a2 01 ed 5b b6 62 b9 — first value 4c checks out, and offset-by-4 gives 5a! Confirmed by the next reads:

// trace
[0x100092210]	0x104c42210	ldurb w10, [x29, #-0x10];     	# x10: 0xaf --> 0x1e,
...
[0x1000922b8]	0x104c422b8	ldurb w10, [x29, #-0xc];      	# x10: 0xd2 --> 0x5b,

So the 16-byte array is read “stood up” — transposed into a matrix:

4c 5a 1e 5b
da a1 a2 b6
e9 0f 01 62
c4 28 ed b9

But then the next group reads a1, a2, b6, da instead of the expected da, a1, a2, b6:

// trace: table index
[0x100092320]	0x104c42320	ldurb w10, [x29, #-0x13];     	# x10: 0xfa --> 0xa1,
...
[0x1000923a8]	0x104c423a8	ldurb w10, [x29, #-0xf];      	# x10: 0x9e --> 0xa2,
...
[0x100092430]	0x104c42430	ldurb w10, [x29, #-0xb];      	# x10: 0xf1 --> 0xb6,
...
[0x1000924b8]	0x104c424b8	ldurb w10, [x29, #-0x17];     	# x10: 0x1 --> 0xda,

That’s AES’s ShiftRows. The 16 bytes are a 4×4 matrix; ShiftRows is a fixed permutation:

So the actual dataflow is ShiftRows first, then SubBytes through the modified S-box — a mixed sub-algorithm of Bangcle’s laes. v44 is still a char pointer, but the contents were transformed before the table lookup:

Then the shifted matrix is transposed:

In code, convert the 16 bytes to a 4×4 matrix, run ShiftRows:

uint8_t *shiftp = (uint8_t *)*v44;
uint8_t state[4][4] = {0};
convert_array(shiftp, state);
ShiftRow(state);
uint8_t * p = (uint8_t *)state;

The in-memory transform:

(lldb) x *v44
0x1327041c0: 4c da e9 c4 5a a1 0f 28 1e a2 01 ed 5b b6 62 b9
-->
(lldb) x p
0x16f18b2a8: 4c 5a 1e 5b a1 a2 b6 da 01 62 e9 0f b9 c4 28 ed

That was the hard part; the rest falls out the same way.

The realization, after much frustration: IDA had been telling us the structure all along — the locals v44 v45 v46 v47 v48 v49 ... v59 are a contiguous array, and the table indices reveal the shift: block 1 reads v44 v48 v52 v56, block 2 reads v49 v53 v57 v45 — that’s ShiftRows, visible in the indexing if you know to look for it.

Final round

Same idea as the rest; just track each sub-step’s inputs and outputs. The full algorithm’s output:

(lldb) x result
0x1327047b0: 77 1a 85 29 14 b6 3c 51 c5 5c b2 2d 52 19 ce 73

Matching against the customized trace output confirms the reconstruction:

What Bangcle actually changed

This is a heavily mutated AES — the skeleton of AES with scrambled internals. And there’s the key. A normal AES-128 key is 16 bytes, used in AddRoundKey; key expansion produces 11 round keys × 16 bytes = 176 bytes. Bangcle’s laes takes a 180-byte key, so the key schedule has been hoisted to the caller. The extra 4 bytes? The first 4 bytes (key[i % 3]) decrypt the remaining 176 — the real key is XOR-obfuscated. My guess at the why: the 176 decrypted bytes really are a key-schedule output, and a schedule is invertible to the master key (see DFA — differential fault analysis). Encrypting the schedule raises the bar.

Add obfuscation on top of this and the difficulty compounds considerably.

Wrap-up

This article walked an Android reverse engineer through iOS: tooling, dump, class-dump, capture, locating the crypto via a base64 hook, active invocation (including the ARM64 stack-argument trap), and finally a full reconstruction of Bangcle’s modified AES — CBC wrapper, ShiftRows + modified S-box rounds, and the hoisted, XOR-protected 180-byte key schedule. The sample app is left as an exercise; grab the latest version from the App Store and follow along.

References:

Colin Xiao — I take on contract reverse-engineering work, and writeups like this are how I show what I can do. If you need help with anything in this space — including but not limited to the areas I cover above — I’m available. No project is too big or too small — if it’s reverse engineering and I can do it, I’ll take it. DM me on X (@colinxiao_lab) or Telegram (@ColinXiao666).

Get in touch