Reverse engineered Shape Android native SDK.
Overview
This was written based on APIGuard 3 SDK 4.7.0 (com.apiguard3) found in com.southwestairlines.mobile.
The native engine ships as an encrypted libag3.so that never hits disk: the obfuscated Java layer decrypts it and maps it from memory through a bundled Google crazy_linker (libcfbe0b.so). The header-building logic lives in a runtime-decrypted secondary DEX. The engine also embeds a custom LuaJIT VM that builds some of the headers (e.g. -g).
Headers
X-dUblrIiu-a
X-dUblrIiu-b
X-dUblrIiu-c
X-dUblrIiu-d
X-dUblrIiu-e
X-dUblrIiu-f
X-dUblrIiu-g
X-dUblrIiu-z
The header-family prefix (X-dUblrIiu-) is per-build and rotates. On app launch there's an init request:
https://mobile.southwest.com/sw_check/android/init
| Header | Source | Notes |
|---|---|---|
-e |
Native SDK | Device fingerprinting. Embeds the native tag. |
-g |
Lua VM | Device fingerprint, built entirely in the LuaJIT VM. |
This repo implements the -e header (and the native tag inside it) and the -g header - encode, decode, and (for -e) full from-scratch generation.
-e header
-e = "b" ; base64url(ChaChaCFB(deflate(sensorJSON), key)) ; base64url(key32)
key = key32 XOR "X-dUblrIiu-" key32 = random 32 bytes per request
The cipher is a ChaCha-family ARX in CFB mode (out[i] = in[i] ^ ks[i] ^ in[i-1]), distinct constants/rounds from the tag cipher. The plaintext is a 22-field sensor JSON:
| Field | Source |
|---|---|
osn |
"Android" (constant) |
osv |
Build.VERSION.RELEASE |
api |
Build.VERSION.SDK_INT |
hwn |
Build.MANUFACTURER + " " + Build.MODEL |
sdk, cid |
APIGuard SDK version / config id (constants) |
uri |
request URL being protected |
hdr |
next rotated header name |
kid |
kernel id (from the /init response) |
pid |
previous kernel id |
eid |
engine error/event log |
mod |
engine integrity result ({res:"{md=N}"} = clean) |
spa kag mpa mpi spi bio bxs age |
engine counters / timers |
sig |
app/runtime signature array (below) |
tag |
native device attestation, D.gt() → [tag0, tag1] |
The sig array (org.json.JSONArray):
| Index | Source |
|---|---|
0 |
PackageInfo.packageName |
1 |
PackageInfo.versionName |
2 |
PackageInfo.firstInstallTime |
3 |
PackageInfo.lastUpdateTime |
4 |
SystemClock.elapsedRealtime() (ms since boot) |
5 |
battery % - round(EXTRA_LEVEL / EXTRA_SCALE * 100) |
6 |
advertising id ("N/A" if unavailable) |
7 |
System.currentTimeMillis() |
8 |
size of the protected request-header map |
9 |
Σ value-lengths of the SDK signal hashtable |
10 |
APK signing-cert hash |
tag
The tag field is [tag0, tag1], the output of the native com.apiguard3.internal.D.gt(...) (gt = "get tag"):
tag0 = base64url(ChaChaCFB(innerJSON, key)) tag1 = base64url(seed)
key = seed XOR "X-dUblrIiu-"
innerJSON = {"nonce": "<unixSecs>-<rand>", "sig": [<Build.FINGERPRINT>, <sample>, <sample>, <statusHex>]}
It uses a separate ChaCha-CFB variant from the -e cipher (different constants, 9 rounds, counter state[9] += 0x3A).
-g header
-g = base64url(ChaChaCFB(plaintext, key)) ; base64url(key32) ; "g"
key = key32 XOR "X-dUblrIiu-" key32 = random 32 bytes per request
plaintext = "1" ";" base64url(zlib(signalJSON))
Unlike -e, the entire -g pipeline runs inside the engine's LuaJIT VM (collection, JSON, zlib, and cipher), so the plaintext never crosses the Java/JNI boundary. It uses a third ChaCha-CFB variant (the f5 "variant g": 10 double-rounds, custom schedule, counter state[5] += 41). The decoded signalJSON is the device-fingerprint / anti-tamper report:
| Field | Contents |
|---|---|
signalCvmAndroidSDKBuildProperties |
Build.* - model, manufacturer, fingerprint, androidId, board… |
signalCvmAndroidAdvertisingId |
advertising id |
signalCvmAndroidFirebaseInstanceId |
Firebase instance id |
signalCvmAndroidHardwareInfo |
cores, total/free RAM + storage, uptime |
signalCvmAndroidLocale |
country, language, currency, timezone |
signalCvmAndroidRootDetection |
root/Magisk detection bitmask |
signalCvmAndroidDrmId |
Widevine DRM id |
signalCvmAndroidPersistentId |
persistent SDK/NDK id blobs |
signalCvmAndroidScreenProperties |
screen brightness |
signalCvmCommonNonce / …Integrator |
per-request nonce / integrator metadata |
signalCvmExtra |
[{key,value}] integrity hashes (kernelId, flag, rooted, all_keys…) |
Utils
Frida Scripts
dump_ag3_java.jshookssetPadding.rm(name, byte[])- itsbyte[]arg is the fully-decrypted ELF on its way to the crazy_linker. Copy it out →libag3.decrypted.sofor IDA.dump_apiguard_dex2.jslocates the runtime DEX (prints the child classloader's decrypted dex path) →adb pull→ decompile to read the builder.dump_lua_protos.jslocates the decoded protos and confirms their layout (sizeof 64,bc[]@+64, consts via((GCRef*)k)[-(i+1)]), plus the ck module chunknames (a0,b1,c2,e4,f5,…,o4).extract_protos.jsdumps all ~337 protos (bytecode + constants) toag3_protos.jsonl.
Disassembler
tools/disasm.js disassembles them. The opcode
permutation was recovered by constraint-solving over the proto corpus using stable
anchors (FUNCF=0x65, RET1=0x31, the <cmp> JMP idiom = 0x64, UGET=0x41,
CALL=0x27, string-load family, …). That makes the logic readable:
f5= the cipher (quarter-round, block, schedule, Encrypt/Decrypt).o4 #98= the-gbuilder pipeline.c2= LibDeflate (CompressZlib) + a base64 codec;b1= the JSON encoder (__tojson,__jsonorder).
o4 #98 disassembles to (cleaned up):
local compressed = enc.CompressZlib(signalJSON) -- zlib
local body = base64url(compressed)
local plaintext = "1;" .. body -- "1" = version, ';' separator
local r5 = generate_random_key(32) -- arc4random
local cipherKey = uv4(r5, 32, get_header_prefix()) -- cipherKey = r5 XOR "X-dUblrIiu-"
local ct = cipher.encrypt(cipherKey, plaintext)
return base64url(ct) .. ";" .. base64url(r5) .. ";" .. eta.IDENTIFIER -- "g"
The cipher (shared core)
All three are one ARX block function in CFB mode with plaintext feedback; only the
parameters differ. See cipher.ts.
state = 16 × uint32: constant words at fixed indices, key words (LE) elsewhere
block = doubleRounds × (8 quarter-rounds over the schedule), then add original state
quarter = a+=b; d=rotl(d^a,r0); c+=d; b=rotl(b^c,r1); a+=b; d=rotl(d^a,r2); c+=d; b=rotl(b^c,r3)
CFB = encrypt out[i] = in[i] ^ ks[i] ^ in[i-1]
decrypt out[i] = in[i] ^ ks[i] ^ out[i-1]
counter = state[counterIndex] += counterAdd (per 64-byte block)
key = randomSeed XOR "X-dUblrIiu-" (first 11 bytes)
-e (E_PARAMS) |
tag (TAG_PARAMS) |
-g (G_PARAMS) |
|
|---|---|---|---|
| rotations | 18,13,11,4 | 19,14,3,10 | 17,18,13,6 |
| rounds | 11 double | 9 | 10 double |
| counter | s[15] += 68 |
s[9] += 0x3A |
s[5] += 41 |
| envelope ord | b ; ct ; key |
ct , seed |
ct ; key ; "g" |
| plaintext | deflate(JSON) |
innerJSON |
"1;"+b64url(zlib(JSON)) |
Supported
-eheader decode / encode:b;<ciphertext>;<key>=tagdecode:[tag0, tag1]-gheader decode / encode:<ciphertext>;<key>=;g- Full
-egeneration from a device profile (tag + sensor JSON + envelope)
Layout
frida/... Frida scripts
src/cipher.ts ChaCha-CFB cipher core (E_PARAMS for -e, TAG_PARAMS for the tag, G_PARAMS for -g)
src/header.ts -e / tag / -g codecs (encode/decode)
src/generator.ts full -e generator: device profile -> sensor JSON -> token
src/main.ts CLI
Usage
Requires Bun. Everything runs through src/main.ts:
bun run decode-e <token> decode an -e header to its sensor JSON
bun run encode-e <sensorJSON> [key32hex] encrypt a sensor JSON into an -e header
bun run decode-tag <tag0> <tag1> decode a native device-attestation tag
bun run encode-tag <innerJSON> [seedhex] encrypt an inner JSON into a [tag0, tag1] pair
bun run decode-g <token> decode a -g header to its signal JSON
bun run encode-g <signalJSON> [key32hex] encrypt a signal JSON into a -g header
bun run generate mint a fresh -e header (now) and show it decodes
encode-* take an optional key/seed (32 bytes as 64 hex chars; random if omitted).
Passing JSON without fighting the shell
A sensor/signal JSON is full of quotes, spaces, braces and backslashes, so passing it as a quoted shell argument is fragile. Every <…JSON> / <token> argument therefore also accepts:
@<path>- read it from a file (recommended for JSON)-- read it from stdin
decode-* print metadata (key, version) to stderr and the JSON to stdout, so you can capture, edit, and re-encode cleanly:
bun src/main.ts decode-e "<token>" > sensor.json # just the sensor JSON lands in the file
$EDITOR sensor.json # tweak a field
bun src/main.ts encode-e @sensor.json <key32hex> # re-encrypt (same key -> decodes identically)
# or in one pipe (re-encrypt in place):
bun src/main.ts decode-e "<token>" | bun src/main.ts encode-e - <key32hex>
Notes
- The cryptography (
tag+-e) is fully synthesized. Device and engine fields (kid,pid, counters,eid, tag integrity samples) come from aDeviceProfile/EngineState- the defaults are a real Xiaomi MI 9 capture; swap them for another device. - zlib framing: Java's
Deflater/ LuaJIT's LibDeflate and Node/Bun'szlibemit different (both-valid) compressed streams, so a generated token decodes correctly but won't byte-match a specific capture.
Disclaimer
For security research and educational purposes only.
Comments