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).

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.

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):


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:
- SubBytes — byte substitution via the S-box.
- ShiftRows — row 1 unchanged, row 2 left by 1, row 3 by 2, row 4 by 3.
- MixColumns — multiply by a fixed matrix.
- 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