The Interpreter

Inside Amazon's AWS WAF

Today we'll be delving into AWS WAF, specifically their invisible JS challenge.

AWS WAF, to my knowledge, has two challenges. One is the invisible JS challenge, the other is their interactive CAPTCHA challenge. We'll be looking into the former, understanding how it works, what they use as signals, and what they could improve on from a defensive lens.

You can identify if a website uses AWS WAF by checking for the awswaf.com domain.

I'll be working backwards from their HTTP flow. The last request that gets made is the mp_verify endpoint. The response simply returns a token to be used on protected endpoints. The request type is POST, and it uses multipart as the content-type. What we're interested in is the actual request payload, so let's take a look.

------geckoformboundary2301e464d2843b54c576d6fd31b7e8fd
Content-Disposition: form-data; name="solution_metadata"

{"challenge":{"input":"c47b824e-9109-47b1-a30d-a20d29683fe8:CgoAiH1qKJx3FAAA:fQgu8SFGgWaPd8+8704NbkpIIjv9t2ZXDAf35qFp06yFDkF+nmDbl06cvbOBikDC6cSFxQ9TanpsSyS08JOqv27DnMHiSDQuuD8vN1MQscrgYKPcjGy6qBSZSWS/DvPq/IRLPcsFJ3epQ2kT/BqHDxjZOwxeAJQuNarQ5Hv3hTOTfFKDU4GHiGLeF6BBcl0Vix2HPr9nPHEeTlV0ojequBajju7fYd22L8HDkM7RPr9zV1TuY9yFrWXtv+aNQlNqIK8cZVkH0WGchBP1vsQtYlrGtXBeysamklg6rrFyCTieJZHkn/KB6585hAbilhUU3iklJdTF7nwL8II877jf9+xIVA==","hmac":"","region":"eu-west-1"},"solution":null,"signals":[{"name":"Zoey","value":{"Present":"qFZAu3uGSR6jMQ/p::d2c604d0ea45a066e6f598ce2cdacba1b9a786ab6e4c94a3de6faf99c36ca6ec50898ff8524f1a90090aa8176220cd24e7efd976b0825e9cd29d4d85ca16c1bade042af9dbb25080aa1076ea61f8e896b9c5517b184c739eb94733b697ee6c62e0fff798d6ef6011251d9f5cb12f754392c9c0a337be24c8e8653cb2408b..."}}],"checksum":"65204E97","existing_token":null,"client":"Browser","domain":"(redacted)","metrics":[{"name":"2","value":0,"unit":"2"},{"name":"100","value":0,"unit":"2"},{"name":"102","value":0,"unit":"2"},{"name":"111","value":16,"unit":"2"},{"name":"103","value":0,"unit":"2"},{"name":"104","value":0,"unit":"2"},{"name":"105","value":0,"unit":"2"},{"name":"106","value":0,"unit":"2"},{"name":"107","value":0,"unit":"2"},{"name":"110","value":0,"unit":"2"},{"name":"108","value":0,"unit":"2"},{"name":"101","value":0,"unit":"2"},{"name":"115","value":0,"unit":"2"},{"name":"114","value":0,"unit":"2"},{"name":"112","value":0,"unit":"2"},{"name":"3","value":0,"unit":"2"},{"name":"7","value":0,"unit":"4"},{"name":"1","value":16.66699999999946,"unit":"2"},{"name":"4","value":0,"unit":"2"},{"name":"5","value":0,"unit":"2"},{"name":"6","value":16.66699999999946,"unit":"2"},{"name":"8","value":1,"unit":"4"}]}
------geckoformboundary2301e464d2843b54c576d6fd31b7e8fd
Content-Disposition: form-data; name="solution_data"

AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA...==
------geckoformboundary2301e464d2843b54c576d6fd31b7e8fd--

This is the entire request payload, and as you can tell the part we want to understand is the solution_metadata, let's format that from one-line JSON.

{
  "challenge": {
    "input": "c47b824e-9109-47b1-a30d-a20d29683fe8:CgoAiH1qKJx3FAAA:fQgu8SFGgWaPd8+8704NbkpIIjv9t2ZXDAf35qFp06yFDkF+nmDbl06cvbOBikDC6cSFxQ9TanpsSyS08JOqv27DnMHiSDQuuD8vN1MQscrgYKPcjGy6qBSZSWS/DvPq/IRLPcsFJ3epQ2kT/BqHDxjZOwxeAJQuNarQ5Hv3hTOTfFKDU4GHiGLeF6BBcl0Vix2HPr9nPHEeTlV0ojequBajju7fYd22L8HDkM7RPr9zV1TuY9yFrWXtv+aNQlNqIK8cZVkH0WGchBP1vsQtYlrGtXBeysamklg6rrFyCTieJZHkn/KB6585hAbilhUU3iklJdTF7nwL8II877jf9+xIVA==",
    "hmac": "",
    "region": "eu-west-1"
  },
  "solution": null,
  "signals": [
    {
      "name": "Zoey",
      "value": {
        "Present": "qFZAu3uGSR6jMQ/p::d2c604d0ea45a066e6f598ce2cdacba1b9a786ab6e4c94a3de6faf99c36ca6ec50898ff8524f1a90090aa8176220cd24e7efd976b0825e9cd29d4d85ca16c1bade042af9dbb25080aa1076ea61f8e896b9c5517b18..."
      }
    }
  ],
  "checksum": "65204E97",
  "existing_token": null,
  "client": "Browser",
  "domain": "(redacted)",
  "metrics": [
    {
      "name": "2",
      "value": 0,
      "unit": "2"
    },
    {
      "name": "100",
      "value": 0,
      "unit": "2"
    },
    {
      "name": "102",
      "value": 0,
      "unit": "2"
    },
    {
      "name": "111",
      "value": 16,
      "unit": "2"
    },
    {
      "name": "103",
      "value": 0,
      "unit": "2"
    },
    {
      "name": "104",
      "value": 0,
      "unit": "2"
    },
    {
      "name": "105",
      "value": 0,
      "unit": "2"
    },
    {
      "name": "106",
      "value": 0,
      "unit": "2"
    },
    {
      "name": "107",
      "value": 0,
      "unit": "2"
    },
    {
      "name": "110",
      "value": 0,
      "unit": "2"
    },
    {
      "name": "108",
      "value": 0,
      "unit": "2"
    },
    {
      "name": "101",
      "value": 0,
      "unit": "2"
    },
    {
      "name": "115",
      "value": 0,
      "unit": "2"
    },
    {
      "name": "114",
      "value": 0,
      "unit": "2"
    },
    {
      "name": "112",
      "value": 0,
      "unit": "2"
    },
    {
      "name": "3",
      "value": 0,
      "unit": "2"
    },
    {
      "name": "7",
      "value": 0,
      "unit": "4"
    },
    {
      "name": "1",
      "value": 16.66699999999946,
      "unit": "2"
    },
    {
      "name": "4",
      "value": 0,
      "unit": "2"
    },
    {
      "name": "5",
      "value": 0,
      "unit": "2"
    },
    {
      "name": "6",
      "value": 16.66699999999946,
      "unit": "2"
    },
    {
      "name": "8",
      "value": 1,
      "unit": "4"
    }
  ]
}

Prior to this, we can see two requests that are .js files. One is jsapi.js, and the other is challenge.js. Looking inside of challenge.js, it's heavily obfuscated.

function a0_0x3740(_0x36d47a, _0x374024) {
  var _0x35d5dc = a0_0x36d4();
  return a0_0x3740 = function (_0x615db1, _0x4d8ea1) {
    _0x615db1 = _0x615db1 - 0;
    var _0x1ba96e = _0x35d5dc[_0x615db1];
    if (a0_0x3740['szoHhe'] === undefined) {
      var _0x1c77e7 = function (_0x539622) {
        var _0x10dbc4 = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789+/=';
        var _0x2cb459 = '',
        _0x3fd478 = '';
        for (
          var _0x4f4ced = 0,
          _0x46cb41,
          _0x2d84f3,
          _0x54be5e = 0;
          _0x2d84f3 = _0x539622['charAt'](_0x54be5e++);
          ~_0x2d84f3 &&
          (
            _0x46cb41 = _0x4f4ced % 4 ? _0x46cb41 * 64 + _0x2d84f3 : _0x2d84f3,
            _0x4f4ced++ % 4
          ) ? _0x2cb459 += String['fromCharCode'](255 & _0x46cb41 >> ( - 2 * _0x4f4ced & 6)) : 0
        ) {
          _0x2d84f3 = _0x10dbc4['indexOf'](_0x2d84f3);
        }
        for (
          var _0x8f281b = 0,
          _0x50cc67 = _0x2cb459['length'];
          _0x8f281b < _0x50cc67;
          _0x8f281b++
        ) {
          _0x3fd478 += '%' + ('00' + _0x2cb459['charCodeAt'](_0x8f281b) ['toString'](16)) ['slice']( - 2);
        }
        return decodeURIComponent(_0x3fd478);
      };
      a0_0x3740['BsZXXP'] = _0x1c77e7,
      _0x36d47a = arguments,
      a0_0x3740['szoHhe'] = !![];
    }
    var _0x57e310 = _0x35d5dc[0],
    _0x18118d = _0x615db1 + _0x57e310,
    _0x245aba = _0x36d47a[_0x18118d];
    return !_0x245aba ? (
      _0x1ba96e = a0_0x3740['BsZXXP'](_0x1ba96e),
      _0x36d47a[_0x18118d] = _0x1ba96e
    ) : _0x1ba96e = _0x245aba,
    _0x1ba96e;
  },
  a0_0x3740(_0x36d47a, _0x374024);
}

This is at the top of the JS file, further down we can see a large array of encoded strings.

function a0_0x36d4() {
  var _0x2acbe4 = [
    'zgvMAw5LuhjVCgvYDhK',
    'x19KzwnVCMf0zq',
    'x19WyxjHBq',
    'x19HD2fPDgvY',
    'x19ZChjLywrbCNjHExm',
    'x19PBxbVCNrezwzHDwX0',
    'x19JBgfZC1bYAxzHDgvgAwvSzeDLDa',
    'igLZig5VDcbHignVBNn0CNvJDg9Yig9Yig51BgW',
    'ChjVDg90ExbL',
    'yxbWBhK'
    ...
    ];

I want to first explain what obfuscation techniques we're seeing here. Immediately you can notice that every variable/function name is renamed to a vague name, this is identifier renaming. Based on the 0x, this suggests to me that they're using hexadecimal renaming.

They're also using this technique we call string array hiding. All the strings live in a large array, sometimes multiple, but in this case it's one massive array. In the large string array, we can see that the strings aren't readable, they're encoded. The code snippet I shared is actually the array decoder. On every call to a0_0x3740, it'll decode the given argument.

Looking at the calls to a0_0x3740, we can see it is being declared as a different variable multiple times throughout the code.

var _0x23201b = a0_0x3740;
Object[_0x23201b(0)](_0x265c21, '__esModule', {
                  'value': !0
                });

This is what we call string array wrappers. This is done to make it harder for an attacker to grep a0_0x3740 across the file, since each scope gets its own alias to the decoder, so there's no symbol an attacker can grep or hook.

Next I want to talk about how string array hiding can prove to be difficult sometimes. With modern string array hiding obfuscators, like obfuscator.io, it's not simply a call to the string array and the argument is the index for said array, the majority of the time they'll have array rotation. Array rotation makes it much harder to decode the strings, and doing it statically becomes a nightmare. This file has a rotation IIFE, but not for the array I shared above:

            !function (_0x1a823e, _0x56574c) {
              for (var _0x8d4948 = _0x4c16ac, _0x521cbb = _0x1a823e(); ; ) try {
                if (
                  203995 === parseInt(_0x8d4948(387)) / 1 + - parseInt(_0x8d4948(363)) / 2 * (parseInt(_0x8d4948(414)) / 3) + parseInt(_0x8d4948(426)) / 4 + parseInt(_0x8d4948(430)) / 5 * ( - parseInt(_0x8d4948(361)) / 6) + - parseInt(_0x8d4948(396)) / 7 * ( - parseInt(_0x8d4948(434)) / 8) + - parseInt(_0x8d4948(418)) / 9 + - parseInt(_0x8d4948(359)) / 10
                ) break;
                _0x521cbb['push'](_0x521cbb['shift']());
              } catch (_0x24431b) {
                _0x521cbb['push'](_0x521cbb['shift']());
              }
            }(_0x5a8377);

This is an example of an array rotator that actually lives inside of the challenge.js file, just for another array. What gives it away is the push/shift ops. It removes the first element and appends it to the end. _0x5a8377 is passed as _0x1a823e, then it does:

for (var _0x8d4948 = _0x4c16ac, _0x521cbb = _0x1a823e(); ; )

This is essentially:

var decoder = _0x4c16ac; var array = _0x5a8377();

So _0x521cbb becomes the actual string array returned by _0x5a8377(). Then it repeatedly evaluates:

203995 === parseInt(_0x8d4948(387)) / 1 
+ -parseInt(_0x8d4948(363)) / 2 * (...) 
+ ...

The important thing is that _0x8d4948 is _0x4c16ac which is presumably our decoder/accessor function. So it's asking the decoder for various things:

_0x4c16ac(387) 
_0x4c16ac(363) 
_0x4c16ac(414) 
...

and using the resulting numbers to calculate a checksum. If the checksum doesn't equal 203995, it rotates the array by one position, then it tries the checksum again. If the calculation throws, it's caught and rotates anyways.

This exact combination of obfuscation techniques (hex names, string array, wrappers, rotation) resembles obfuscator.io, it's a classic obfuscator.io feature set.

Luckily there are tools out there that help us with de-obfuscating files like this, to turn them from mumbo jumbo to actual readable JS. The one I like to use is Babel. Babel turns JS into an AST that you can then walk to dynamically replace the calls to the decoder with the actual string. Or, there are other alternatives that do this for you, like webcrack.

In this demonstration we'll be using webcrack, since it's the fastest way to get this done. Webcrack does have its limitations however, but for this file it should be able to deobfuscate it and then we can start working on the actual reversing part.

Webcrack has deobfuscated the file, leaving us with the vague hexadecimal naming that we can rename along the way as we reverse this. The first thing I want to understand is where exactly Zoey comes from, and since the string hiding is now deobfuscated, we can grep for it.

    var _0x4df2bb = function () {
      function _0x55e260() {}
      _0x55e260.prototype.provide = function () {
        var _0x1413a7 = {
          identifier: "Zoey"
        };
        _0x1413a7.material = [1311738035, 312286030, 2043623273, 3937688527];
        return _0x1413a7;
      };
      return _0x55e260;
    }();

This is essentially:

var _0x4df2bb = (function () {
    function Provider() {}

    Provider.prototype.provide = function () {
        return {
            identifier: "Zoey",
            material: [
                1311738035,
                312286030,
                2043623273,
                3937688527
            ]
        };
    };

    return Provider;
})();

Material seems interesting, but what we can do from here is work backwards. What actually calls _0x4df2bb? Let's find out.

Searching for _0x4df2bb gives us this:

        var _0x4d3d8b = this;
        this.initializationErrors = [];
        this.metrics = [];
        var _0x474729 = null;
        var _0x4ff783 = new _0x5070d6.FWCIMObjectEncoder(new _0x5070d6.JSONEncoder(), new _0x5070d6.UTF8Encoder(), new _0x5070d6.HexEncoder(), new _0x5070d6.CRC32Calculator());
        var _0x425f0e = new _0x234797(new _0x4df2bb(), new _0x5070d6.Base64Encoder());

I now want to know where _0x234797 is created, let's search for that.

    var _0x234797 = function () {
      function _0x1bb1d5(_0x370a49, _0x5837f5) {
        this.keyProvider = _0x370a49;
        this.base64Encoder = _0x5837f5;
      }
      _0x1bb1d5.prototype.encrypt = function (_0x4d1715) {
        return _0x54a1ea(this, undefined, undefined, function () {
          var _0x2a3a73;
          var _0x2b6269;
          var _0x4ced3f;
          var _0x2d8886;
          return _0x386c5a(this, function (_0x51694f) {
            var _0xe0074e;
            switch (_0x51694f.label) {
              case 0:
                _0x2a3a73 = this.keyProvider.provide();
                _0x2b6269 = _0x2cf637.getRandomValues(new Uint8Array(12));
                return [4, _0x2cf637.subtle.importKey("raw", this.hexToBytes("6f71a512b1e035eaab53d8be73120d3fb68a0ca346b9560aab3e5cdf753d5e98"), {
                  name: "AES-GCM"
                }, false, ["encrypt"])];
              case 1:
                _0x4ced3f = _0x51694f.sent();
                return [4, this.doEncrypt(_0x4d1715, _0x4ced3f, _0x2b6269)];
              case 2:
                _0x2d8886 = _0x51694f.sent();
                return [2, _0x2a3a73.identifier + "::" + this.base64Encoder.encode(String.fromCharCode.apply(null, _0x2b6269)) + "::" + this.bytesToHexString(_0x2d8886)];
            }
          });
        });
      };
      _0x1bb1d5.prototype.doEncrypt = function (_0xb0a867, _0x520e56, _0x57a5b8) {
        return _0x54a1ea(this, undefined, undefined, function () {
          var _0x2857fa;
          return _0x386c5a(this, function (_0x16044c) {
            switch (_0x16044c.label) {
              case 0:
                _0x2857fa = new _0x33f6ab().encode(_0xb0a867);
                var _0x397a98 = {
                  name: "AES-GCM",
                  iv: _0x57a5b8,
                  tagLength: 128
                };
                return [4, _0x2cf637.subtle.encrypt(_0x397a98, _0x520e56, _0x2857fa)];
              case 1:
                return [2, _0x16044c.sent()];
            }
          });
        });
        switch (_0x5cb908.label) {
          case 0:
            var _0x3bcbf2 = {
              plaintext: _0x4c6363
            };
            _0xf58b57.log("starting encrypt..", _0x3bcbf2);
            _0x1a3483 = new _0x73f6c2().encode(_0x398d75);
            _0x504c3a.log(`encoded: ${_0x23902c.stringify(_0xd69d8a)}`);
            var _0x4470b9 = {
              name: "AES-GCM",
              iv: _0x150f64,
              tagLength: 128
            };
            return [4, _0x2c6c06.subtle.encrypt(_0x4470b9, _0x2ba2de, _0x1c42d8)];
          case 1:
            return [2, _0x4c376a.sent()];
        }
      };
      _0x1bb1d5.prototype.bytesToHexString = function (_0x21ec12) {
        if (!_0x21ec12) {
          return null;
        }
        for (var _0x1f1e4b = new Uint8Array(_0x21ec12), _0x5976dd = [], _0x509704 = 0; _0x509704 < _0x1f1e4b.length; ++_0x509704) {
          var _0x432d2d = _0x1f1e4b[_0x509704].toString(16);
          if (_0x432d2d.length < 2) {
            _0x432d2d = "0" + _0x432d2d;
          }
          _0x5976dd.push(_0x432d2d);
        }
        return _0x5976dd.join("");
        try {
          _0x46a887(_0x1b0490.throw(_0x524da1));
        } catch (_0x7e9833) {
          _0x2e879c(_0x7e9833);
        }
      };
      _0x1bb1d5.prototype.hexToBytes = function (_0x3b73ab) {
        if (_0x3b73ab.length % 2 != 0) {
          throw "Invalid hex";
        }
        var _0x4d9520 = new Uint8Array(_0x3b73ab.length / 2);
        for (var _0x4e42ad = 0; _0x4e42ad < _0x3b73ab.length; _0x4e42ad += 2) {
          var _0x579150 = parseInt(_0x3b73ab.substr(_0x4e42ad, 2), 16);
          if (Number.isNaN(_0x579150)) {
            throw "Invalid hex";
          }
          _0x4d9520[_0x4e42ad / 2] = _0x579150;
        }
        return _0x4d9520;
      };
      return _0x1bb1d5;
    }();
    var _0x70fc9 = "#";

Now we can see exactly what is actually going on, let me rewrite this into more readable code.

class Encryptor {
    constructor(keyProvider, base64Encoder) {
        this.keyProvider = keyProvider;
        this.base64Encoder = base64Encoder;
    }

    async encrypt(input) {
        const provider = this.keyProvider.provide();

        const iv = crypto.getRandomValues(
            new Uint8Array(12)
        );

        const key = await crypto.subtle.importKey(
            "raw",
            this.hexToBytes(
                "6f71a512b1e035eaab53d8be73120d3fb68a0ca346b9560aab3e5cdf753d5e98"
            ),
            { name: "AES-GCM" },
            false,
            ["encrypt"]
        );

        const encrypted = await this.doEncrypt(input, key, iv);

        return (
            provider.identifier +
            "::" +
            this.base64Encoder.encode(
                String.fromCharCode(...iv)
            ) +
            "::" +
            this.bytesToHexString(encrypted)
        );
    }

    async doEncrypt(input, key, iv) {
        const bytes = new SomeEncoder().encode(input);

        return crypto.subtle.encrypt(
            {
                name: "AES-GCM",
                iv: iv,
                tagLength: 128
            },
            key,
            bytes
        );
    }
}

This is essentially:

Identifier :: Base64(IV) :: Hex(AES-GCM-CipherText + Tag)

And now that we know where the encryption happens, we can simply set a breakpoint on the variable that encrypts the string instead of hand-walking the entire signal builder.

{
  "metrics": {
    "fp2": 0,
    "capabilities": 0,
    "canvas": 0,
    "gpu": 0,
    "dnt": 0,
    "math": 0,
    "screen": 0,
    "navigator": 0,
    "subtle": 0,
    "auto": 0,
    "browser": 0,
    "be": 0,
    "cdp": 0,
    "stealth": 0,
    "formdetector": 0
  },
  "start": 1791020365324,
  "flashVersion": null,
  "plugins": [
    {
      "name": "PDF Viewer",
      "str": "PDF Viewer "
    },
    {
      "name": "Chrome PDF Viewer",
      "str": "Chrome PDF Viewer "
    },
    {
      "name": "Chromium PDF Viewer",
      "str": "Chromium PDF Viewer "
    },
    {
      "name": "Microsoft Edge PDF Viewer",
      "str": "Microsoft Edge PDF Viewer "
    },
    {
      "name": "WebKit built-in PDF",
      "str": "WebKit built-in PDF "
    }
  ],
  "dupedPlugins": "PDF Viewer Chrome PDF Viewer Chromium PDF Viewer Microsoft Edge PDF Viewer WebKit built-in PDF ||1920-396-396-24-*-*-*",
  "screenInfo": "1920-396-396-24-*-*-*",
  "capabilities": {
    "css": {
      "textShadow": 1,
      "WebkitTextStroke": 1,
      "boxShadow": 1,
      "borderRadius": 1,
      "borderImage": 1,
      "opacity": 1,
      "transform": 1,
      "transition": 1
    },
    "js": {
      "audio": true,
      "geolocation": true,
      "localStorage": "supported",
      "touch": false,
      "video": true,
      "webWorker": true
    },
    "elapsed": 0
  },
  "canvas": {
    "hash": 1537688547,
    "emailHash": null,
    "histogramBins": [
      0,
      0,
      0,
      0,
      0,
      0,
      0,
      0,
      0,
      0,
      0,
      1125,
      0,
      1125
      ...
    ]
  },
  "gpu": null,
  "dnt": null,
  "math": {
    "tan": "-1.4214488238747245",
    "sin": "0.8178819121159085",
    "cos": "-0.5753861119575491"
  },
  "crypto": {
    "crypto": 1,
    "subtle": 1,
    "encrypt": true,
    "decrypt": true,
    "wrapKey": true,
    "unwrapKey": true,
    "sign": true,
    "verify": true,
    "digest": true,
    "deriveBits": true,
    "deriveKey": true,
    "getRandomValues": true,
    "randomUUID": true
  },
  "automation": {
    "wd": {
      "properties": {
        "document": [],
        "window": [],
        "navigator": []
      }
    },
    "phantom": {
      "properties": {
        "window": []
      }
    }
  },
  "referrer": "",
  "userAgent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:154.0) Gecko/20100101 Firefox/154.0",
  "location": "https://(redacted)/",
  "webDriver": false,
  "be": {
    "si": false
  },
  "cdp": {
    "pw": [],
    "pp": [],
    "pf": "na",
    "cd": [],
    "xb": []
  },
  "formDetected": true,
  "numForms": 1,
  "numFormElements": 2,
  "end": 1791020365324,
  "errors": [],
  "version": "2.4.0",
  "id": "d647eef7-924c-4978-be7e-f1e8c4544b15"
}

This is what it encrypts, along with a prefix of 497FFBC6#, but I removed that in the JSON above to preserve the format. The 497FFBC6 is the checksum, and # separates the two. This checksum is different to the request payload above because this is a separate payload. Let's figure out what that checksum is. We already got a hint before with the CRC32Calculator() call, but let's find it rather than assume, then we can start digging into the actual signals being collected.

So after digging around for a while, I found module 994, which is where the CRC32 implementation lives. Once the constant arrays were inlined, the implementation became much easier to read:

class CRC32 {
    buildCrcTable() {
        this.crcTable = [];

        for (let i = 0; i < 256; i++) {
            let crc = i;

            for (let j = 0; j < 8; j++) {
                if (crc & 1) {
                    crc = (crc >>> 1) ^ 3988292384;
                } else {
                    crc >>>= 1;
                }
            }

            this.crcTable[i] = crc;
        }
    }

    calculate(input) {
        if (!this.crcTable) {
            this.buildCrcTable();
        }

        let crc = 0 ^ 4294967295;

        for (let i = 0; i < input.length; i++) {
            const index = 255 & (crc ^ input.charCodeAt(i));
            crc = (crc >>> 8) ^ this.crcTable[index];
        }

        return 4294967295 ^ crc;
    }
}

The value 3988292384 is 0xEDB88320, which is the standard reversed polynomial used by the IEEE CRC-32 algorithm. So the CRC32Calculator() name we saw earlier wasn't just a coincidence, we can now confirm that this is actually calculating CRC32.

There's one other important detail here. calculate() operates on the string using charCodeAt(). That means we need to know exactly what the utf8Encoder in the previous Encryptor class produces before we can reproduce the checksum byte for byte.

Looking back at the encoder:

const bytes =
    this.utf8Encoder.encode(
        this.jsonEncoder.encode(input)
    );

const checksum = this.crc32.calculate(bytes);

The data being checksummed is therefore:

Input object -> JSON Encoding -> UTF-8 encoding -> CRC32 -> Hex encoding

For the captured payload, the resulting checksum was:

497FFBC6

And the final plaintext supplied to the encryption layer was effectively:

497FFBC6#<JSON encoded signal data>

The # is simply the separator between the checksum and the encoded signal data.

At this point, we have traced the checksum all the way down to its implementation instead of treating the CRC32Calculator() reference as a guess. The next step is therefore to identify exactly what _0x33f6ab().encode() is doing, because that determines the precise input to CRC32.calculate(). Once that's established, we can reproduce the checksum independently.

Looking at _0x33f6ab, we can see that it is either the browser's native TextEncoder or a custom fallback:

var _0x33f6ab =
    typeof window.TextEncoder === "function"
        ? window.TextEncoder
        : _0x2bfe6f;

The fallback _0x2bfe6f implements UTF-8 encoding manually. It takes a JavaScript string, converts each character into its UTF-8 representation, and returns the resulting bytes.

However, there's another encoder in the code that is even more relevant to the CRC32 calculation. The signal collector creates:

this.hexEncoder = new HexEncoder();
this.crcCalculator = new CRC32();
this.utf8Encoder = new UTF8Encoder();

and later, when the form value is processed, we get the following chain:

this.checksum =
    this.hexEncoder.encode(
        this.crcCalculator.calculate(
            this.utf8Encoder.encode(JSONSignals)
        )
    );

This finally explains the 497FFBC6# prefix we saw earlier.

The value being processed is first converted into UTF-8 bytes, those bytes are passed through the CRC-32 implementation, and the resulting 32-bit integer is converted into uppercase hexadecimal.

Now that we've solved the cryptography, the checksum, and what is being encrypted, we can now go to understanding what signals are being collected. Most of the values in the JSON are self-explanatory, but the one that caught my eye is the histogramBins.

At first glance, this looks like it could be some sort of complicated statistical calculation, but tracing createHistogram() shows that it is actually very simple.

createHistogram(data) {
    for (; index < 256; histogram[index++] = 0);

    for (var i = 0; i < data.length; i++) {
        histogram[data[i]]++;
    }

    return histogram;
}

The canvas is first passed through getImageData(), which gives us the raw RGBA pixel data. Since the canvas is 150 x 60 pixels and each pixel contains four values (red, green, blue and alpha), this gives us 36,000 individual byte values.

createHistogram() then creates 256 bins, representing every byte value from 0 to 255. It iterates over every value in the pixel buffer and uses that value as an index into the histogram, incrementing the corresponding counter.

For example, if the pixel data contained:

[255, 0, 255, 255, 10, 20, 30, 255]

the resulting histogram would contain:

histogram[0]   = 1
histogram[10]  = 1
histogram[20]  = 1
histogram[30]  = 1
histogram[255] = 4

There is also no separation between the individual RGBA channels. It is one combined histogram containing the frequency of every byte value across the entire canvas.

This gives us another representation of the canvas rendering in addition to the CRC32 hash. The canvas.hash reduces the rendered output down to a single checksum, while histogramBins preserves some information about the overall distribution of the resulting pixel values.

This is interesting for fingerprinting because the pixel data is produced by the browser's rendering pipeline. Differences in rendering behaviour can affect the pixel distribution, meaning the histogram can provide more information alongside the canvas hash.

Now that we understand what histogramBins is actually measuring, we can look at the canvas itself.

The collector creates a 150 x 60 canvas and then performs a large number of different drawing operations on it.

For example, it draws text using specific fonts:

ctx.font = "8pt Arial";
ctx.fillText("Cwm fjordbank glyphs vext quiz,", 2, 15);

It draws multiple shapes and circles, uses different colours, transparency, gradients and compositing modes, and performs various mathematical operations before drawing their results:

ctx.fillStyle = "rgb(255,0,255)";
ctx.arc(...);

ctx.globalCompositeOperation = "multiply";

ctx.createLinearGradient(...);

Math.tan(-1e+300).toString();
Math.cos(-1e+300).toString();
Math.sin(-1e+300).toString();

It even performs more complicated path operations using quadratic curves. The important part is that all of these operations eventually become pixels.

Different browsers, operating systems, graphics stacks, GPUs, drivers, fonts and rendering implementations can produce subtle differences in the final pixel output. Even when the canvas looks visually identical to a human, the underlying pixel data can differ.

That makes the canvas useful for fingerprinting.

The collector therefore takes two different approaches to the resulting image.

First, it serializes the canvas with:

canvas.toDataURL()

and feeds that representation into the CRC32 calculator:

CRC32.calculate(canvas.toDataURL())

This produces the canvas.hash.

The next field I want to understand is the be and cdp fields. I'll first track down the cdp fields.

I initially thought this was simply checking whether Chrome's DevTools protocol was available, but after following the constants it became clear that it was actually looking for several different automation frameworks.

The first interesting one was this array:

var cdpArtifacts = [ "cdc_adoQpoasnfa76pfcZLmcfl_Array", "cdc_adoQpoasnfa76pfcZLmcfl_Promise", "cdc_adoQpoasnfa76pfcZLmcfl_Symbol", "cdc_adoQpoasnfa76pfcZLmcfl_Object", "cdc_adoQpoasnfa76pfcZLmcfl_Proxy", "cdc_adoQpoasnfa76pfcZLmcfl_JSON", "cdc_adoQpoasnfa76pfcZLmcfl_Window" ];

At first I had no idea what adoQpoasnfa76pfcZLmcfl was supposed to mean. As it turns out, it isn't a meaningful word or encoded value. The important part is the cdc_ prefix, and the fact these are known ChromeDriver artifacts. ChromeDriver has historically injected globals using names such as these.

The random looking middle part is better thought of as part of the identifier rather than something we need to reverse, or decode. There is another array immediately above it:

var playwrightArtifacts = [ "__pwInitScripts", "__playwright__binding__" ];

These are Playwright specific globals that can be exposed in the page's JS environment. We also have:

var puppeteerArtifacts = [ "__puppeteer_evaluation_script__", "pptr:" ];

Which points towards Puppeteer specific traces. This means the cdp object isn't simply returning a single CDP detected boolean. It is collecting several different observations that can be used to distinguish between automation frameworks.

The next question for me becomes how these are actually checked. The first helper used by both the Playwright and ChromeDriver checks is _0x2e86bc(). Here is the code simplified:

function findProperties(object, properties) {
    if (!isObjectLike(object)) {
        return null;
    }
    return properties.filter(function(property) {
        try {
            return property in object;
        } catch {
            return false;
        }
    });
}

This is just taking an object and an array of property names, then returning which properties actually exist in the object.

Now that we know how those checks work, we can look at the Puppeteer check, which is slightly different. Instead of looking for properties on window, it creates an Error object and examines its stack trace:

var stack = new window.Error().stack;

It then filters the list of Puppeteer markers and returns which strings are present in the stack. The final check xb is more involved. Instead of checking for specific global names, it gets all of the properties on window:

var names = Object.getOwnPropertyNames(window);

It then gets the property descriptor for each one and only continues if the property contains a value that is a function. For each function, it checks whether the function itself has properties called:

"__installed" 
"callbacks"

If either of those properties exists, the detector records that an injected function was found. So we can conclude what each check does.

Key Source
pw Search window for Playwright properties
pp Search Error.stack for puppeteer markers
pf Currently returns "na"
cd Search window for ChromeDriver properties
xb Inspect window functions for injected automation artifacts

Now that we've mapped those out, we can move to be.

Searching for "be" literally got me here:

_0x21d13f.collectorName = "be";

And just above that tells the story for be, let me simplify it.

class BeCollector {
    collectData() {
        const scripts = document.querySelectorAll("head > script");
        let detected = false;

        for (const script of scripts) {
            const src = script.src;

            if (
                (src.startsWith("moz-extension://") &&
                 src.endsWith("/assets/prompt.js")) ||
                src === "chrome-extension://mooikfkahbdckldjjndioackbalphokd/assets/prompt.js"
            ) {
                detected = true;
            }
        }

        return {
            be: {
                si: detected
            }
        };
    }
}

It does two things, one is for Firefox and the other is Chrome. For Firefox it looks for a <script> under <head>, then makes sure its src starts with moz-extension://, and makes sure it ends with /assets/prompt.js. For Chrome it looks for the exact extension resource.

mooikfkahbdckldjjndioackbalphokd is the exact extension ID for the Selenium IDE extension, so this is simply checking if the browser has Selenium capabilities.

Now that we've gone through those fields, we can map the rest given all of them are self-explanatory.

{
  // per-collector elapsed-time counters (ms). keys = the collector roster.
  "metrics": { "fp2":0, "capabilities":0, "canvas":0, "gpu":0, "dnt":0,
               "math":0, "screen":0, "navigator":0, "subtle":0, "auto":0,
               "browser":0, "be":0, "cdp":0, "stealth":0, "formdetector":0 },

  "start": 1791020365324,          // epoch-ms, collection start
  "end":   1791020365324,          // epoch-ms, collection end (== start -> ~0ms elapsed)

  "flashVersion": null,            // legacy Adobe Flash version (dead tech, null expected)

  "plugins": [ ... ],              // navigator.plugins - {name, descriptor string}
  "dupedPlugins": "...||1920-...", // plugin strings concatenated + "||" + screenInfo (combined into one signature string)
  "screenInfo": "1920-396-396-24-*-*-*", // dash-joined screen metrics (width / avail dims / colorDepth ...); "*" = field unavailable

  "capabilities": {
    "css": { ... },               // CSS feature-support flags (1 = supported)
    "js":  { ... },               // JS API support: audio, geolocation, localStorage, touch, video, webWorker
    "elapsed": 0                  // time this collector took
  },

  "canvas": {
    "hash": 1537688547,           // canvas-render fingerprint hash
    "emailHash": null,            // secondary/seeded canvas hash (unused here)
    "histogramBins": [ ... ]      // pixel-value histogram of the rendered canvas
  },

  "gpu":  null,                   // WebGL renderer/vendor (null = unavailable/blocked)
  "dnt":  null,                   // Do Not Track setting
  "math": { "tan":..., "sin":..., "cos":... }, // Math.{tan,sin,cos} of a fixed input -> JS-engine/FPU float fingerprint

  "crypto": { ... },              // SubtleCrypto method availability flags (capability/consistency probe)

  "automation": {
    "wd":      { "properties": { "document":[], "window":[], "navigator":[] } }, // webdriver-property probe, empty = none found
    "phantom": { "properties": { "window":[] } }                                // PhantomJS-property probe
  },

  "referrer":  "",                             // document.referrer
  "userAgent": "Mozilla/5.0 ... Firefox/154.0",// navigator.userAgent
  "location":  "https://(redacted)/",          // page URL
  "webDriver": false,                          // navigator.webdriver

  "be":  { "si": false },         // Selenium Check
  "cdp": { "pw":[], "pp":[], "pf":"na", "cd":[], "xb":[] }, // Browser automation artifact checks

  "formDetected": true,           // a <form> was found on the page
  "numForms": 1,                  // document.forms.length
  "numFormElements": 2,           // total form input elements

  "errors": [],                   // collection errors
  "version": "2.4.0",             // SDK version
  "id": "d647eef7-..."            // per-session UUID
}

The Defensive Lens

So starting off with the main and most important point, AWS WAF decided to hardcode the AES "secret" key into the code. It's hidden inside the string array, but that is still bad practice. Obfuscating the JS, splitting the key across multiple variables, etc. only increases extraction effort, and when webcrack can deobfuscate the bundle in a few passes, hardcoding the key is not the way to go. You may be wondering why, and it's because one key is used across every session. The key does not change, so as soon as it's found once it's public. I'd recommend turning it into a per-session secret issued by the server, that way it's not one key to hundreds of thousands of boxes.

The next is their obfuscation. It's really weak, and if their goal was to slow down attackers, using obfuscator.io type obfuscation was not the way to go. As we went through, an attacker can run the file through webcrack a few times and it'll completely deobfuscate the file. If they want to go in the direction of slowing down attackers, they need to move towards where the industry is moving, bytecode VMs. This raises the cost for the attackers dramatically, but this is more a time buy with a shelf life and real maintenance/perf cost.

The next point ties back to my last post regarding PX. This time there actually is quite a lot of collection going on, but it's all static and forgeable. They're missing one signal that is very good for detection. The canvas and histogram bins are a good fingerprinting technique, but what would bring this up a level is mouse movements/key presses. This is a more invasive approach, but it is definitely one of the harder fields to forge. It makes it difficult for both approaches, CDP driven automation and offline generation.

That's it for this post, stay tuned for the next one.