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.js hooks setPadding.rm(name, byte[]) - its byte[] arg is the fully-decrypted ELF on its way to the crazy_linker. Copy it out → libag3.decrypted.so for IDA.
  • dump_apiguard_dex2.js locates the runtime DEX (prints the child classloader's decrypted dex path) → adb pull → decompile to read the builder.
  • dump_lua_protos.js locates 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.js dumps all ~337 protos (bytecode + constants) to ag3_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 -g builder 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

  • -e header decode / encode: b;<ciphertext>;<key>=
  • tag decode: [tag0, tag1]
  • -g header decode / encode: <ciphertext>;<key>=;g
  • Full -e generation 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 a DeviceProfile / 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's zlib emit 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.