While reversing the Farfetch Android app, I noticed they use multiple anti-fraud solutions. We already covered Akamai BMP, but there’s another header that caught my eye: X-Castle-Request-Token.

Castle.io is a fraud detection service. After dealing with Akamai’s AES encryption, RSA key wrapping, and proof-of-work challenges, I figured Castle would be similarly complex. Spoiler: it wasn’t, but it took me a while to figure that out.


First Look

Here’s what the header looks like in a captured request:

X-Castle-Request-Token: 5u6w35-fq5bSlL6f0I7RnpWcqKCOrtOfv5GHnKG_s63Sgc6ihbLmuHSToHxXeUnSQuLjqpFU6BpUxgqqBoJlyWepb7IFoGSLVZYU7ydEkSWS6AOTAPsQ4RzBRq5QrHLDbOU9x1fPCVhcxzuQZMJLyGS3Zc9kqQrSArB52giDa9Rm...

Base64url encoded, ~287 characters. The token changes with every request, so there’s definitely some timestamp or random component.


Finding the SDK Classes

First step - find Castle’s classes in the APK:

Java.perform(function() {
    Java.enumerateLoadedClasses({
        onMatch: function(className) {
            if (className.toLowerCase().indexOf("castle") !== -1) {
                console.log("[Class] " + className);
            }
        },
        onComplete: function() {}
    });
});

This dumps a ton of classes:

io.castle.android.Castle
io.castle.android.CastleConfiguration
io.castle.android.CastleActivityLifecycleCallbacks
io.castle.android.api.CastleAPIService
io.castle.highwind.android.Highwind
io.castle.highwind.android.b
io.castle.highwind.android.c
io.castle.highwind.android.d
io.castle.highwind.android.e
io.castle.highwind.android.g
io.castle.highwind.android.j
...

The highwind package has all those obfuscated single-letter classes - that’s clearly where the token generation lives.


Is It Native Code?

My first thought was to check if Highwind uses native code. With Akamai BMP, everything was Java, but I wanted to be sure:

Java.perform(function() {
    Process.enumerateModules().forEach(function(m) {
        if (m.name.toLowerCase().indexOf("castle") !== -1 || 
            m.name.toLowerCase().indexOf("highwind") !== -1) {
            console.log("[lib] " + m.name + " @ " + m.path);
        }
    });
});

Output:

[lib] boot-bouncycastle.oat @ /system/framework/arm64/boot-bouncycastle.oat

That’s just Android’s system BouncyCastle crypto library, not a Castle native lib. But wait - when I looked at stack traces later, they showed (Native Method) for Highwind.token(). That threw me off for a bit.

Let me verify properly:

var Highwind = Java.use("io.castle.highwind.android.Highwind");
var methods = Highwind.class.getDeclaredMethods();
methods.forEach(function(m) {
    var mods = m.getModifiers();
    var isNative = (mods & 0x100) !== 0;
    console.log("[Highwind." + m.getName() + "] native=" + isNative);
});
[Highwind.token] native=false

Not native! The (Native Method) in stack traces was just a Frida artifact. Good to know - pure Java means we can fully reverse it with JADX.


Hooking Token Generation

Let’s find the entry point. Listing methods on the main Castle class:

var Castle = Java.use("io.castle.android.Castle");
Castle.class.getDeclaredMethods().forEach(function(m) {
    console.log("[Castle] " + m.getName());
});
[Castle] createRequestToken
[Castle] configure
[Castle] identify
[Castle] track
[Castle] screen
[Castle] flush
...

createRequestToken - that’s our target. Hook it:

var Castle = Java.use("io.castle.android.Castle");
Castle.createRequestToken.implementation = function() {
    var token = this.createRequestToken();
    console.log("[Castle.createRequestToken] " + token);
    return token;
};

var Highwind = Java.use("io.castle.highwind.android.Highwind");
Highwind.token.overloads.forEach(function(overload) {
    overload.implementation = function() {
        var token = overload.apply(this, arguments);
        console.log("[Highwind.token] " + token);
        return token;
    };
});

Now I can see tokens being generated as the app runs:

[Highwind.token] 9v6gz4-Pu4bChK6PwJ7BjoWMuLCevsOPr4GXjLGvo73Ckd6ymHcfsHAavFdDpl-jwah3DF_kUEwL55hvWaP3DDiI...
[Highwind.token] nZXLpOTk0O2p78Xkq_Wq5e7n09v11ajkxOr859rEyNap-rXZ8xx02xtx1zwozTTIqsMcZ7wHs6_oBHuMukAU79tr...
[Highwind.token] qKD-kdHR5dic2vDRnsCf0NvS5u7A4J3R8d_J0u_x_eOcz4DsxilB7i5E4gkd-AH9n_YpUgG6DhJVucYxB_2pUmbW...

Different every time. There’s definitely randomness involved.


Trying to Catch the Plaintext

With Akamai BMP, I hooked Cipher.doFinal() to catch the plaintext before encryption. Let me try the same approach:

var Cipher = Java.use("javax.crypto.Cipher");
Cipher.doFinal.overload("[B").implementation = function(data) {
    var result = this.doFinal(data);
    var algo = this.getAlgorithm();
    if (data.length > 20 && data.length < 500) {
        var str = Java.use("java.lang.String").$new(data, "UTF-8");
        console.log("[Cipher." + algo + "] input: " + str);
    }
    return result;
};

Nothing. No Cipher hooks triggered for Castle tokens.

Okay, let’s try Base64:

var Base64 = Java.use("android.util.Base64");
Base64.encodeToString.overload("[B", "int").implementation = function(data, flags) {
    var result = this.encodeToString(data, flags);
    if (data.length >= 50 && data.length <= 300) {
        console.log("[Base64] len=" + data.length + " flags=" + flags);
        
        var hex = "";
        for (var i = 0; i < Math.min(data.length, 64); i++) {
            hex += ("0" + (data[i] & 0xff).toString(16)).slice(-2) + " ";
        }
        console.log("  hex: " + hex);
    }
    return result;
};
[Base64] len=128 flags=2
  hex: 28 c6 f9 4e 80 ac 48 32 92 13 81 13 8d df fd c0 45 4b 08 52 52 83 30 c6 7b 46 6a 5f b2 05 7d da...

[Base64] len=128 flags=2
  hex: 85 5b be 5b a3 5d 30 e9 5d 69 94 4f 07 71 99 f1 09 90 38 21 b1 a0 76 97 c3 c8 6d 7e e0 87 4b 17...

High entropy binary data - already encrypted/encoded before Base64. So there’s no AES encryption, but something is scrambling the data before encoding.


Finding the Encoding Class

Let me hook the obfuscated classes to see which one does the encoding. Starting with class b:

var B = Java.use("io.castle.highwind.android.b");
B.a.overloads.forEach(function(overload) {
    overload.implementation = function() {
        var result = overload.apply(this, arguments);
        console.log("[b.a] args=" + arguments.length + " result=" + result);
        if (arguments.length > 0) {
            console.log("  arg0: " + arguments[0]);
        }
        return result;
    };
});

Jackpot:

[b.a] args=0
  result: 2

[b.a] args=1
  arg0: 34,42,116,27,91,91,111,82,22,80,122,91,20,74,21,90,81,88,108,100,74,106,23,91,123,85,67,88,101,123,119,105...
  result: Iip0G1tbb1IWUHpbFEoVWlFYbGRKahdbe1VDWGV7d2kWRQpmKhEE5yT0YXOntlGdtRqVFztlNZRO7ezMHKmDr32CidQfi4Lt...

Class b.a() takes a byte array and returns a Base64url string. The input bytes look like they’re already XOR’d (mix of printable and non-printable).


Extracting the Configuration

Now let’s see what data goes into the token. Hooking the j class constructor:

var J = Java.use("io.castle.highwind.android.j");
J.$init.overloads.forEach(function(overload) {
    overload.implementation = function() {
        console.log("[j.<init>] args=" + arguments.length);
        for (var i = 0; i < arguments.length; i++) {
            console.log("  arg" + i + ": " + arguments[i]);
        }
        return overload.apply(this, arguments);
    };
});
[j.<init>] args=4
  arg0: fd57e75f-db73-4658-a395-d67427b380dd
  arg1: 2.1.4
  arg2: Farfetch/5.26.0 (1160) (Castle 2.1.4; Android 16; Google Pixel 7a)
  arg3: pk_V9yyMp4rXy6h7xszNFhH5yYwazGYUK4g

Nice! We have:

  • Device UUID
  • SDK Version - “2.1.4”
  • User-Agent
  • Publishable Key - starts with pk_

And hooking when tokens are generated to see the stored fields:

Highwind.token.overloads.forEach(function(overload) {
    overload.implementation = function() {
        var jField = Highwind.class.getDeclaredField("a");
        jField.setAccessible(true);
        var jInstance = jField.get(this);
        
        var J = Java.use("io.castle.highwind.android.j");
        ["a","b","c","d","e"].forEach(function(fname) {
            var f = J.class.getDeclaredField(fname);
            f.setAccessible(true);
            console.log("[j." + fname + "] " + f.get(jInstance));
        });
        
        return overload.apply(this, arguments);
    };
});
[j.a] pk_V9yyMp4rXy6h7xszNFhH5yYwazGYUK4g
[j.b] 08
[j.c] ff
[j.d] 712dac3787c444ee9aaaea997a2c94a9
[j.e] 2844

Wait - j.d is 712dac37... but the UUID was fd57e75f.... They don’t match. I was confused here for a bit until I realized j.d is probably a different device ID or the UUID with dashes removed and possibly hashed.


JADX Deep Dive

Time to open JADX and look at the actual algorithm. The Highwind.token() method:

public final String token() throws NumberFormatException {
    j jVar = this.f36969a;
    jVar.c();
    
    int time = (int) ((new Date().getTime() / 1000) - 1535000000);
    String strA = p.a(new Random().nextInt(256));
    String string = Integer.toString(new Random().nextInt(16) & 15, 16);
    
    r rVarA = jVar.b().a();
    String str = rVarA.f37016a;
    String strA2 = p.a((rVarA.f37017b & 31) | ((jVar.a() & 7) << 5));
    String strSubstring = jVar.f36993a.substring(3);
    
    // ... lots more code ...
    
    return jVar.a(strStringPlus.getBytes(Charsets.ISO_8859_1));
}

Key observations:

  1. Timestamp: (Date.getTime() / 1000) - 1535000000 - seconds since ~Aug 2018
  2. Random byte: new Random().nextInt(256)
  3. Random nibble: new Random().nextInt(16) & 15
  4. Fingerprint data from jVar.b().a()

First Implementation Attempt

Based on my reading of the code, I wrote a Go generator. My understanding was:

// Build payload
finalHex := version + encodedPK + encodedSDK + deviceID + hash2
finalHex += fmt.Sprintf("%02x", len(finalHex))

// XOR with random byte
xored := xorHexStrings(finalHex, randomByteHex)
xored = randomByteHex + xored

// Base64url encode
return base64.RawURLEncoding.EncodeToString(hexToBytes(xored))

Testing it:

Generated token length: 76
Real token length: 287

Way off. 76 vs 287 characters. I’m missing a lot of data.


Where’s the Missing Data?

I figured the fingerprint must be included in the payload. Added it:

finalHex := version + encodedPK + encodedSDK + fingerprintData + deviceID + hash2
Generated token length: 332
Real token length: 287

Now it’s too long! 332 vs 287.

At this point I was going in circles. The fingerprint seems to be used but maybe not directly? Let me trace through more carefully…


The Hash Chain

Looking at the JADX code again, I noticed the hash functions:

String strA3 = aVar.a(strStringPlus3, 4, strStringPlus3.toCharArray()[3], string2);
String strA4 = aVar.a(str3, 8, str3.toCharArray()[9], Intrinsics.stringPlus(strStringPlus3, strA3));

The t.a.a() method XORs the ENTIRE key string with a rotated version of the input. So if combined (which includes the fingerprint) is 300+ characters, the hash output will ALSO be 300+ characters!

This is where I’d been going wrong. The fingerprint isn’t directly in the payload - it’s used as the KEY for hashing, and the hash output length depends on the key length.


The Breakthrough: StringBuilder Hook

I needed to see exactly what was being built. Hooked StringBuilder:

var SB = Java.use("java.lang.StringBuilder");
SB.toString.implementation = function() {
    var s = this.toString();
    if (s.length > 100 && s.length < 500 && /^[0-9a-f]+$/.test(s)) {
        console.log("[StringBuilder] " + s.length + " chars: " + s.substring(0, 60) + "...");
    }
    return s;
};

This was the breakthrough:

[StringBuilder] 312 chars: 03000c06476f6f676c651405656e2d55531e4927...  <- Fingerprint
[StringBuilder] 316 chars: 4e8a1a851cce75e67de57f9d1fec74a44fda04...  <- +hash1 start
[StringBuilder] 324 chars: 891afe654e8a1a851cce75e67de57f9d1fec74...  <- More hash
[StringBuilder] 428 chars: 08563979794d703472587936683778737a4e46...  <- Final payload!
[StringBuilder] 430 chars: af08563979794d703472587936683778737a4e...  <- +random byte

428 hex chars = 214 bytes = ~285 base64 chars. That’s almost exactly 287!

Now I could see the structure:

  • Fingerprint: 312 hex chars
  • Final payload: 428 hex chars
  • After XOR: 430 hex chars (428 + 2 for random byte prefix)

Decoding the Final Payload

Let me decode that 428-char payload:

final_hex = "08563979794d703472587936683778737a4e46684835795977617a4759554b3467..."
print(bytes.fromhex(final_hex[:40]))
# b'\x08V9yyMp4rXy6h7xszNFh'

It starts with 08 (version) then V9yyMp4rXy6h7xszNFhH5yYwazGYUK4g - that’s the publishable key!

The structure is:

Component Size (hex) Content
Version 2 08
Encoded PK 64 Each char of PK as hex
Encoded SDK 4 SDK version encoded
Device ID 32 UUID without dashes
Hash2 324 XOR hash output
Length 2 Payload length mod 256
Total 428

The math finally works:

  • hash1 = length of combined = 2 + 312 + 2 = 316
  • hash2 = length of xoredTime + hash1 = 8 + 316 = 324
  • Total = 2 + 64 + 4 + 32 + 324 + 2 = 428 ✓

The Fingerprint Format

Decoding the fingerprint hex:

fp = bytes.fromhex("03000c06476f6f676c651405656e2d5553...")

# Field 0: header=0x03 -> index=0, type=ENUM(3), value=0x00
# Field 1: header=0x0c -> index=1, type=STRING(4), length=6, value="Google"
# Field 2: header=0x14 -> index=2, type=STRING(4), length=5, value="en-US"

Interesting - field 1 is “Google” (manufacturer), not “Pixel 7a” (model). The fingerprint includes ~20 fields with device info, screen size, CPU cores, hardware features, etc.


Final Understanding

The complete token generation:

  1. Timestamp: Seconds since Aug 23, 2018
  2. Random byte + nibble: For XOR keys
  3. Build fingerprint: ~20 device fields, each with type-specific encoding
  4. XOR timestamp with random nibble
  5. Calculate hash1: XOR fingerprint with rotated timestamp chars
  6. Calculate hash2: XOR (timestamp + hash1) with rotated device ID
  7. Assemble payload: version + encoded_pk + encoded_sdk + device_id + hash2 + length
  8. XOR entire payload with random byte
  9. Base64url encode (no padding)

No AES. No RSA. No HMAC. Just XOR operations and Base64. Much simpler than Akamai BMP.


Comparison with Akamai BMP

Feature Castle Akamai BMP
Encryption XOR only AES-128-CBC + RSA
Signing None HMAC-SHA256
Encoding Base64url Base64
Fingerprint Fields ~20 35+
Sensor Data Location only Accel, Gyro, Touch
Proof of Work None SHA-256 challenges
Complexity Medium High

Castle relies on obfuscation rather than cryptographic strength. Once you understand the XOR chain and field encoding, generating valid tokens is straightforward.


Key Takeaways

  1. Don’t assume native code - Stack traces showing “(Native Method)” can be misleading with Frida
  2. Hook StringBuilder - Great way to see payloads being assembled piece by piece
  3. Watch the sizes - Token length told me immediately when I was missing or adding too much data
  4. XOR hash length = key length - This was the key insight I missed initially

The debugging process was as valuable as the final result. Each wrong assumption led to a hook that revealed more about the actual implementation.


Go Implementation

Core token generation:

func (g *CastleGenerator) GenerateToken() string {
    // Timestamp (seconds since Aug 23, 2018)
    timestamp := int(time.Now().Unix() - 1535000000)
    
    // Random components
    randomByte := rand.Intn(256)
    randomNibble := rand.Intn(16) & 0x0F
    
    // Build fingerprint (~312 hex chars)
    fingerprint, fieldCount := g.buildFingerprint()
    
    // Combined flags for hashing
    combined := combinedFlags + fingerprint + flags
    
    // XOR timestamp with random nibble
    xoredTime := xorHexStrings(timeHex, randomNibbleHex) + randomNibbleHex
    
    // Hash chain - output length = input key length!
    hash1 := calculateTHash(xoredTime, 4, xoredTime[3], combined)  // len = ~316
    hash2 := calculateTHash(deviceID, 8, deviceID[9], xoredTime+hash1)  // len = ~324
    
    // Assemble final payload
    payload := version + encodedPK + encodedSDK + deviceID + hash2
    payload += fmt.Sprintf("%02x", len(payload) & 0xFF)
    
    // XOR with random byte and encode
    xored := fmt.Sprintf("%02x", randomByte) + xorHexStrings(payload, fmt.Sprintf("%02x", randomByte))
    return base64.RawURLEncoding.EncodeToString(hexToBytes(xored))
}

Full implementation on GitHub.


This research is for educational purposes. Understanding anti-fraud systems helps build better security.