Compiling a Custom Browser to Bypass Anti-Bot Measures
August 11, 2023
• nullpt.rs
In this blog post, I will be documenting the journey veritas and I took to extract the AES keys and browser flags/fingerprint from the Supreme anti-bot system. This work was done using the ticket.js
This article may have been republished from another source and might not have been originally written for this site.⚠️ Some information, tools, or techniques discussed may have changed or evolved since the publishing of this article.
Originally published at https://nullpt.rs/compiling-browser-to-bypass-antibot-measures

Preamble
In this blog post, I will be documenting the journey veritas and I took to extract the AES keys and browser flags/fingerprint from the Supreme anti-bot system. This work was done using the ticket.js
anti-bot from March, 2021, and is being published now that Supreme has migrated away from the ticket anti-bot system in favour of using Shopify. Extracting these keys allows for a complete bypass of the anti-bot system.
Chapter 1: Background
Supreme, renowned for its streetwear apparel, "drops" (merchandise release) its products in extremely limited numbers. This makes it highly desirable for scalpers who use bots to buy these products in bulk in order to sell them for insane markups. In the Fall/Winter 2016 collection, Supreme sold a red clay brick for 250 at it's peak. 1 In response to this trend, Supreme took action by unveiling its proprietary anti-bot solution, initially dubbed
pooky.js
. The anti-bot that Supreme used for their Spring 2021 season was known as "ticket" due to the file ticket.js
that created the required cookies. During this time, the implementation and obfuscation techniques of the anti-bot were constantly evolving and it would be common to have a new version for each drop. This is likely due to how much information was publicly known about the mechanics of the anti-bot. While the exact implementation changed quite a bit throughout the evolution of ticket, starting in JS then moving to WASM then back to JS, each version essentially operated in the same manner.### Cookie Format
The main result from executing the ticket.js
script is the ntbcc
cookie, which is essentially a proof-of-work that is used in subsequent requests to Supreme. In the construction of this cookie, several browser challenges are used to form a browser fingerprint. These challenges included straightforward webdriver checks, canvas fingerprinting, checking for media playback through the use of the canPlayType()
method, and various other techniques used to classify the environment the user was executing the script in. This fingerprint is embedded in the cookie in an encrypted form and Supreme decrypts this cookie on the server to verify the fingerprint matches one of the allowed web browsers. This is done in an attempt to prevent botting, as constructing a single cookie correctly would seemingly require running all of the challenges in a genuine web browser. In reality, once the browser fingerprint was determined, many of these cookies could be generated and then encrypted using the encryption key stored somewhere in the ticket.js
file.
New Request: /live.json
During this season, a new request was added as a part of the ticket anti-bot system. A request is sent to /live.json
and Supreme responds with a (seemingly) randomly generated token. The following is an example of the body of the response:
1{
2 "v": "a3c8ee7949fab93156c66ffb5f2de4b0a305337550b42407a5f2441cedfab5d8dc8d8ee8c2026e13e7b960211928f1c97edd5d93218d759c9c06fb6d0bf4aeb503ea17528561a083f6a0ff3548dd440b"
3}
4In the object of this response, there's a single v
field with a value of 160 hexadecimal values, representing a sequence of 80-bytes. Through reverse-engineering of ticket.js
, it was known that these 80-bytes contained two values:
64-bytes ciphertext
2 - 16-bytes IV (Initialization Vector)
When generating a ntbcc
cookie, the ciphertext is decrypted using a dedicated decryption key and is inserted in the decrypted form of the cookie.
Cookie Generation Procedure
To properly construct a ticket cookie the following three pieces of information are required:
A:
live.json
decryption key - B: Browser-specific fingerprint
C: Final
ntbcc
cookie encryption key
Using these three pieces of information, a copious number of ntbcc
cookies can be arbitrarily generated using the following procedure:
Send a request to
/live.json
to obtain a token to embed in thentbcc
cookie. - Use (A) to decrypt the value of live token
Concatenate the decrypted token
with (B) to create the plaintext form of the3ntbcc
cookie - Use (C) to encrypt the plaintext form of the ntbcc
cookie4
This procedure can be seen below:
Example Cookie Generation Code
1const aesjs = require('aes-js');
2const { randomBytes } = require('crypto');
3
4const generateCookie = ({ liveToken, liveDecryptionKey, cookieEncryptionKey, fingerprint }) => {
5 const liveCipherLength = 128; // in hex characters (64 bytes)
6 const liveCipherText = aesjs.utils.hex.toBytes(liveToken.slice(0, liveCipherLength));
7 const liveIv = aesjs.utils.hex.toBytes(liveToken.slice(liveCipherLength));
8 const liveAesCbc = new aesjs.ModeOfOperation.cbc(liveDecryptionKey, liveIv);
9
10 const liveDecrypted = liveAesCbc.decrypt(liveCipherText);
11
12 const liveLengthInCookie = 48; // (in bytes)
13 const livePortion = Array.from(liveDecrypted.slice(0, liveLengthInCookie));
14
15 const cookieDecrypted = livePortion.concat(fingerprint);
16 const cookieIv = Array.from(randomBytes(16));
17 const cookieAesCbc = new aesjs.ModeOfOperation.cbc(cookieEncryptionKey, cookieIv);
18
19 const cookie = cookieAesCbc.encrypt(aesjs.padding.pkcs7.pad(cookieDecrypted));
20 const cookieHex = aesjs.utils.hex.fromBytes(Array.from(cookie).concat(cookieIv));
21
22 return cookieHex;
23}
24
25// The value `v` from the /live.json response
26const liveToken = "3a765667139687af19c7486afefb5e5880d8cd1dcda9ee3ad46732776eed35413c96b3ef4ae7611b85db10ab76b0f0d56bdbfa17fd57eb423ae16234dcf98f9e04f24ac7d03879063c55a88ecb2d439a";
27
28const liveDecryptionKey = [
29 0xb6, 0x3a, 0xd0, 0x39, 0xcb, 0xca, 0x2c, 0xc5,
30 0xd4, 0xfc, 0x30, 0x43, 0x93, 0x90, 0xfa, 0x55,
31 0xac, 0x9e, 0x5f, 0xc2, 0x79, 0xcf, 0x9d, 0x94,
32 0x34, 0x92, 0x54, 0xf2, 0xa4, 0xf7, 0x9f, 0x4e
33];
34
35const cookieEncryptionKey = [
36 0x79, 0x81, 0x5a, 0xd3, 0xf6, 0x53, 0x8e, 0xcc,
37 0x19, 0x77, 0xc9, 0x9d, 0xe5, 0x04, 0x34, 0x26,
38 0x61, 0x3d, 0xe9, 0xc3, 0xf2, 0xfe, 0x28, 0xdf,
39 0x52, 0x83, 0xf6, 0x1d, 0xb3, 0xad, 0x4f, 0x78
40];
41
42const fingerprint = [
43 0x4d, 0x61, 0xab, 0x0f, 0xce, 0xaa, 0x32, 0x46,
44 0x52, 0x75, 0x35, 0x08, 0xef, 0x84, 0x78, 0xc1,
45 0xd1, 0x9a, 0xc5, 0x3b, 0xa5, 0xa2, 0xac, 0xd6,
46 0xd9, 0x6b, 0xca, 0xf6, 0xc0, 0x79, 0x82, 0xca,
47 0xaf, 0x8d, 0x86, 0x3a, 0x89, 0xe5, 0x0a, 0x5c,
48 0xa0, 0xa9, 0x40, 0x1c, 0x04, 0x74, 0xd7, 0x89,
49 0x28, 0x12, 0x55, 0x4f, 0x52, 0x34, 0x0c, 0x6d,
50 0x6c, 0x65, 0x72, 0x2e, 0xe7, 0xde, 0x29, 0x4e,
51 0xd3, 0x46, 0xf9, 0xdd, 0xd5, 0xc6, 0x90, 0x50
52];
53
54const cookieHex = generateCookie({ liveToken, liveDecryptionKey, cookieEncryptionKey, fingerprint });
55
56// the value of the `ntbcc` cookie (all of the browser fingerprints collected)
57console.log(cookieHex);
58Which can be condensed as:
1const ntbcc = encrypt(
2 decrypt(
3 live.v.cipher, // first 64 bytes
4 decryptKey,
5 live.v.decryptIv // last 16 bytes
6 ).slice(0, 48) + fingerprint,
7 encryptKey,
8 encryptIv
9) + encryptIv
10aes-js Analysis
To perform the encryption and decryption functions, ticket.js
uses the aes-js library (or something very similar to it). Therefore, this section includes code snippets from the aes-js project created by ricmoo which is licensed under the MIT License. These snippets are presented to illustrate the approach used to extract the decryption and encryption keys.
aes-js License
1The MIT License (MIT)
2Copyright (c) 2015 Richard Moore
3Permission is hereby granted, free of charge, to any person obtaining a copy
4of this software and associated documentation files (the "Software"), to deal
5in the Software without restriction, including without limitation the rights
6to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
7copies of the Software, and to permit persons to whom the Software is
8furnished to do so, subject to the following conditions:
9The above copyright notice and this permission notice shall be included in
10all copies or substantial portions of the Software.
11THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
12IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
13FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
14AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
15LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
16OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
17THE SOFTWARE.Decryption
The ultimate goal is to obtain the decryption key in order to be able to decrypt the live.json
token. Usually, the decryption key would be fairly trivial to extract. However, in an effort to make this process more difficult, ticket.js
removes the decryption and encryption keys from source and instead inlines the expanded keys. The value of _Kd
(expanded decryption key) used throughout the aes-js decrypt function can be used to obtain the original decryption key 5. The following is the implementation of the decrypt function with 3 comments added.
1AES.prototype.decrypt = function(ciphertext) {
2 if (ciphertext.length != 16) {
3 throw new Error('invalid ciphertext size (must be 16 bytes)');
4 }
5
6 var rounds = this._Kd.length - 1;
7 var a = [0, 0, 0, 0];
8
9 var t = convertToInt32(ciphertext);
10 for (var i = 0; i < 4; i++) {
11 t[i] ^= this._Kd[0][i]; // (A) Simple XOR with first expanded key
12 }
13
14 for (var r = 1; r < rounds; r++) {
15 for (var i = 0; i < 4; i++) {
16 a[i] = (T5[(t[ i ] >> 24) & 0xff] ^
17 T6[(t[(i + 3) % 4] >> 16) & 0xff] ^
18 T7[(t[(i + 2) % 4] >> 8) & 0xff] ^
19 T8[ t[(i + 1) % 4] & 0xff] ^
20 this._Kd[r][i]); // (B) XOR with the second through fourteenth expanded key
21 }
22 t = a.slice();
23 }
24
25 var result = createArray(16), tt;
26 for (var i = 0; i < 4; i++) {
27 tt = this._Kd[rounds][i];
28 // (C) XOR performed separately for each byte for the fifteenth expanded key
29 result[4 * i ] = (Si[(t[ i ] >> 24) & 0xff] ^ (tt >> 24)) & 0xff;
30 result[4 * i + 1] = (Si[(t[(i + 3) % 4] >> 16) & 0xff] ^ (tt >> 16)) & 0xff;
31 result[4 * i + 2] = (Si[(t[(i + 2) % 4] >> 8) & 0xff] ^ (tt >> 8)) & 0xff;
32 result[4 * i + 3] = (Si[ t[(i + 1) % 4] & 0xff] ^ tt ) & 0xff;
33 }
34
35 return result;
36}
37As described in the comments, the value of _Kd
is used in three separate ways in the function:
A: Simple XOR with first expanded key
B: XOR with the second through fourteenth expanded key
C: XOR performed separately for each byte for the fifteenth expanded key
With a known plaintext and a capture of the inputs for each XOR operation, it would be possible to reconstruct the value of _Kd
and thus obtain the decryption key. However, there are many challenges faced using this approach and this is far easier said than done. While in earlier versions of ticket (December 2020), it was possible to easily transform the AST (Abstract Syntax Tree) of ticket.js
to essentially hook all XOR operations, this is no longer feasible due to the updated obfuscation techniques. Additionally, extracting the fifteenth round would prove to be challenging, as the values are all calculated byte-by-byte. Because of this, the inputs for each XOR operation are incredibly small and would be difficult to identify between all other irrelevant XOR operations.
Encryption
The ultimate goal is to obtain the encryption key in order to be able to encrypt the final ntbcc
cookie. The value of _Ke
(expanded encryption key) used throughout the aes-js encrypt function can be used to obtain the original encryption key 6. The following is the implementation of the encrypt function with 3 comments added.
1AES.prototype.encrypt = function(plaintext) {
2 if (plaintext.length != 16) {
3 throw new Error('invalid plaintext size (must be 16 bytes)');
4 }
5
6 var rounds = this._Ke.length - 1;
7 var a = [0, 0, 0, 0];
8
9 var t = convertToInt32(plaintext);
10 for (var i = 0; i < 4; i++) {
11 t[i] ^= this._Ke[0][i]; // (A) Simple XOR with first expanded key
12 }
13
14 for (var r = 1; r < rounds; r++) {
15 for (var i = 0; i < 4; i++) {
16 a[i] = (T1[(t[ i ] >> 24) & 0xff] ^
17 T2[(t[(i + 1) % 4] >> 16) & 0xff] ^
18 T3[(t[(i + 2) % 4] >> 8) & 0xff] ^
19 T4[ t[(i + 3) % 4] & 0xff] ^
20 this._Ke[r][i]); // (B) XOR with the second through fourteenth expanded key
21 }
22 t = a.slice();
23 }
24
25 var result = createArray(16), tt;
26 for (var i = 0; i < 4; i++) {
27 tt = this._Ke[rounds][i];
28 // (C) XOR performed separately for each byte for the fifteenth expanded key
29 result[4 * i ] = (S[(t[ i ] >> 24) & 0xff] ^ (tt >> 24)) & 0xff;
30 result[4 * i + 1] = (S[(t[(i + 1) % 4] >> 16) & 0xff] ^ (tt >> 16)) & 0xff;
31 result[4 * i + 2] = (S[(t[(i + 2) % 4] >> 8) & 0xff] ^ (tt >> 8)) & 0xff;
32 result[4 * i + 3] = (S[ t[(i + 3) % 4] & 0xff] ^ tt ) & 0xff;
33 }
34
35 return result;
36}
37As described in the comments, the value of _Ke
is used in three separate ways in the function:
A: Simple XOR with first expanded key
B: XOR with the second through fourteenth expanded key
C: XOR performed separately for each byte for the fifteenth expanded key
Which is... exactly how _Kd
is used in the decryption function. However, the plaintext input to the encrypt function must somehow be known. After that is solved and once the challenges described in the decryption section are dealt with, the approach can be easily adapted to obtain the encryption key. The main differences between the encrypt and decrypt functions are simply the transformation and substitution tables that are used in each function.
Chapter 2: Extracting the Decryption Key
The following two chapters were initially implemented in JavaScript that was loaded alongside a modified ticket.js
. However, over time this became challenging as the obfuscation used on ticket improved and ultimately that implementation was replaced by a C++ implementation which was added to a customized version of firefox. The very first change that was made was the change described in Evading JavaScript Anti-Debugging Techniques. Afterwards, the changes described in the next two chapters were made to ultimately have a custom firefox browser that was capable of extracting the decryption/encryption keys and browser fingerprint from the ticket anti-bot.
Adding Custom Sources
The next changes made were the creation of the following three files:
aes_constants.cpp
: stored lookup tables for encryption transformations (T1, T2, T3, T4), decryption (T5, T6, T7, T8), and the transformations for the decryption key expansion (U1, U2, U3, U4).aes_helper.cpp
: held functions for performing the various AES operations (encrypt, decrypt, substitution, etc.) and a function to convert the decryption expanded key to the original decryption key.ticket.cpp
: held the key extraction logic described over the next two chapters.
Then, to have these files included in the firefox compilation, they were added to the SOURCES
array in moz.build
:
1--- a/js/src/moz.build
2+++ b/js/src/moz.build
3@@ -449,6 +449,9 @@ UNIFIED_SOURCES += [
4 SOURCES += [
5 "builtin/RegExp.cpp",
6 "jsmath.cpp",
7+ "ticket/aes_constants.cpp",
8+ "ticket/aes_helper.cpp",
9+ "ticket/ticket.cpp",
10 "util/DoubleToString.cpp",
11 "util/Utility.cpp",
12 "vm/Interpreter.cpp",
13AES Utility Functions
As previously mentioned, several different AES utility functions were created to perform the decryption and encryption operations. These functions are so common you could probably get ChatGPT to write them 7 and are just included here to cover every aspect of the procedure.
aes_helper
aes_helper.h
:
1#ifndef ticket_aes_helper_h
2#define ticket_aes_helper_h
3
4#include <stdint.h>
5
6namespace ticket {
7 #define ENCRYPT_ROUND_COUNT (2)
8 #define DECRYPT_ROUND_COUNT (15)
9 #define AES_BLOCK_SIZE (4)
10 #define AES_BLOCK_SIZE_BYTES ((AES_BLOCK_SIZE) * 4)
11
12 #define BYTE0(num) (((num) >> 24) & 0xFF)
13 #define BYTE1(num) (((num) >> 16) & 0xFF)
14 #define BYTE2(num) (((num) >> 8) & 0xFF)
15 #define BYTE3(num) ((num) & 0xFF)
16 #define BE_NUM(a, b, c, d) ((a << 24) | (b << 16) | (c << 8) | (d))
17
18 #define ROUND_BYTE(round_key, idx, byte_idx) ((round_key)[(idx) % AES_BLOCK_SIZE].bytes[(byte_idx)])
19
20 typedef struct decrypt_round_keys {
21 uint32_t round_keys[DECRYPT_ROUND_COUNT][AES_BLOCK_SIZE];
22 } decrypt_round_keys_t;
23
24 typedef struct encrypt_round_keys {
25 uint32_t round_keys[ENCRYPT_ROUND_COUNT][AES_BLOCK_SIZE];
26 } encrypt_round_keys_t;
27
28 uint32_t calculate_encrypt(const uint32_t round_key[AES_BLOCK_SIZE], int base);
29
30 uint32_t calculate_decrypt(const uint32_t round_key[AES_BLOCK_SIZE], int base);
31
32 uint32_t expand_decryption_key(const uint32_t round_key);
33
34 void decrypt_with_round_keys(
35 const uint32_t ciphertext[AES_BLOCK_SIZE],
36 const decrypt_round_keys_t *key,
37 uint32_t result[AES_BLOCK_SIZE]
38 );
39
40 void decrypt_rounds_to_key(
41 decrypt_round_keys_t *key,
42 uint32_t result[8]
43 );
44
45 void encrypt_rounds_to_key(
46 const uint32_t last_two_encrypt_rounds[2][AES_BLOCK_SIZE],
47 uint8_t result[32]
48 );
49}
50#endif
51aes_helper.cpp
:
1#include "ticket/aes_helper.h"
2
3#include <iostream>
4
5#include <stdint.h>
6#include <stdio.h>
7#include <string.h>
8
9#include "ticket/aes_constants.h"
10
11namespace ticket {
12 uint32_t calculate_encrypt(const uint32_t round_key[AES_BLOCK_SIZE], int base) {
13 return T1[BYTE0(round_key[(base + 0) % 4])] ^
14 T2[BYTE1(round_key[(base + 1) % 4])] ^
15 T3[BYTE2(round_key[(base + 2) % 4])] ^
16 T4[BYTE3(round_key[(base + 3) % 4])];
17 }
18
19 uint32_t calculate_decrypt(const uint32_t round_key[AES_BLOCK_SIZE], int base) {
20 return T5[BYTE0(round_key[(base + 4) % 4])] ^
21 T6[BYTE1(round_key[(base + 3) % 4])] ^
22 T7[BYTE2(round_key[(base + 2) % 4])] ^
23 T8[BYTE3(round_key[(base + 1) % 4])];
24 }
25
26 uint32_t calculate_substitution(const uint32_t round_key[AES_BLOCK_SIZE], int base) {
27 return BE_NUM(Si[BYTE0(round_key[(base + 4) % 4])],
28 Si[BYTE1(round_key[(base + 3) % 4])],
29 Si[BYTE2(round_key[(base + 2) % 4])],
30 Si[BYTE3(round_key[(base + 1) % 4])]);
31 }
32
33 uint32_t expand_decryption_key(const uint32_t round_key) {
34 return U1[BYTE0(round_key)] ^
35 U2[BYTE1(round_key)] ^
36 U3[BYTE2(round_key)] ^
37 U4[BYTE3(round_key)];
38 }
39
40 void decrypt_with_round_keys(
41 const uint32_t ciphertext[AES_BLOCK_SIZE],
42 const decrypt_round_keys_t *key,
43 uint32_t result[AES_BLOCK_SIZE]
44 ) {
45 int rounds = DECRYPT_ROUND_COUNT - 1;
46 uint32_t a[] = {0, 0, 0, 0};
47
48 // convert plaintext to (ints ^ key)
49 uint32_t t[AES_BLOCK_SIZE];
50
51 for (int i = 0; i < AES_BLOCK_SIZE; i++) {
52 t[i] = ciphertext[i] ^ key->round_keys[0][i];
53 }
54
55 // apply round transforms
56 for (int r = 1; r < rounds; r++) {
57 for (int i = 0; i < AES_BLOCK_SIZE; i++) {
58 a[i] = calculate_decrypt(t, i) ^ key->round_keys[r][i];
59 }
60 memcpy(t, a, AES_BLOCK_SIZE * 4);
61 }
62
63 // the last round is special
64 for (int base = 0; base < 4; base++) {
65 result[base] = calculate_substitution(t, base) ^ key->round_keys[rounds][base];
66 }
67 }
68
69 void decrypt_rounds_to_key(
70 decrypt_round_keys_t *key,
71 uint32_t result[8]
72 ) {
73 std::cout << "decrypt_rounds_to_key" << std::endl;
74 int last_round = DECRYPT_ROUND_COUNT - 1;
75 int second_last_round = last_round - 1;
76
77 for (int i = 0; i < 12; i++) {
78 key->round_keys[second_last_round][i % 4] = expand_decryption_key(key->round_keys[second_last_round][i % 4]);
79 }
80
81 for (int i = 0; i < AES_BLOCK_SIZE; i++) {
82 // First 16 bytes are the last round
83 result[i] = key->round_keys[last_round][i];
84
85 // Last 16 bytes
86 result[AES_BLOCK_SIZE + i] = key->round_keys[second_last_round][i];
87 }
88 }
89}
90Redirect /live.json Request
Next, the request to /live.json
was redirected to simplify the decryption key extraction process. This simplifies it by having a known ciphertext and IV used in the decrypt()
function which does not need to be intercepted or determined in another way. The following is the patch used to modify this request and use a /live.json
served on a custom domain:
1--- a/dom/fetch/InternalRequest.h
2+++ b/dom/fetch/InternalRequest.h
3@@ -143,7 +143,12 @@ class InternalRequest final : public AtomicSafeRefCounted<InternalRequest> {
4 MOZ_ASSERT(!aURL.IsEmpty());
5 MOZ_ASSERT(!aURL.Contains('#'));
6
7- mURLList.AppendElement(aURL);
8+ if (aURL.Equals("https://www.supremenewyork.com/live.json")) {
9+ mURLList.AppendElement("https://example.com/live.json"_ns);
10+ } else {
11+ mURLList.AppendElement(aURL);
12+ }
13
14 mFragment.Assign(aFragment);
15 }
16Decryption IV Initialization
Now is a convenient time to initialize the ticket decryption key extraction code. The hard-coded encryption IV is added to the ticket_init()
function in ticket.cpp
:
1void ticket_init(ticket_state_t *state) {
2 std::cout << "ticket_init" << std::endl;
3 state->done = 0;
4 state->current_decrypt_round_index = 0;
5
6 // From the hard-coded /live.json response
7 state->decrypt_iv[0] = BE_NUM(0xfc, 0x12, 0xc1, 0xeb);
8 state->decrypt_iv[1] = BE_NUM(0x26, 0x50, 0x74, 0x07);
9 state->decrypt_iv[2] = BE_NUM(0xc5, 0x30, 0xcb, 0x45);
10 state->decrypt_iv[3] = BE_NUM(0xb9, 0x4d, 0xf5, 0x0d);
11
12 // From the hard-coded /live.json response
13 state->current_decrypt_round[0] = state->decrypt_payload[0] = BE_NUM(0x1e, 0xb6, 0x87, 0x77);
14 state->current_decrypt_round[1] = state->decrypt_payload[1] = BE_NUM(0x63, 0x43, 0x0c, 0x42);
15 state->current_decrypt_round[2] = state->decrypt_payload[2] = BE_NUM(0x3a, 0x60, 0x3f, 0xf7);
16 state->current_decrypt_round[3] = state->decrypt_payload[3] = BE_NUM(0xf2, 0x15, 0x94, 0xc4);
17}
18Capture XOR Operations
The next step is to capture all the XOR operations that occur in JS. To do this, the two operands to the bitwise XOR operation are captured by modifying the interpreter BitXorOperation()
function:
1--- a/js/src/vm/Interpreter.cpp
2+++ b/js/src/vm/Interpreter.cpp
3@@ -1525,6 +1660,8 @@ static MOZ_ALWAYS_INLINE bool BitXorOperation(JSContext* cx,
4 return BigInt::bitXorValue(cx, lhs, rhs, out);
5 }
6
7+ ticket::ticket_on_xor(&state, (uint32_t) lhs.toInt32(), (uint32_t) rhs.toInt32());
8+
9 out.setInt32(lhs.toInt32() ^ rhs.toInt32());
10 return true;
11 }
12Great! Now all XOR operations can be captured and can be used to extract the decryption and encryption keys. After quickly testing it by printing out lhs
and rhs
, it seems like it works... until it doesn't. This is due to the baseline JIT (potentially even then the IonMonkey JIT) which is replacing the slow calls to BitXorOperation()
with native machine code. This is normally great as it allows for some JavaScript code to finish executing before the heat death of universe, but makes capturing all XOR operations very difficult. This was easily fixed by disabling the JIT, which unfortunately makes all JS execution even slower:
1--- a/js/src/vm/Interpreter.cpp
2+++ b/js/src/vm/Interpreter.cpp
3@@ -3225,7 +3362,7 @@ static MOZ_NEVER_INLINE JS_HAZ_JSNATIVE_CALLER bool Interpret(JSContext* cx,
4 // Use the slow path if the callee is not an interpreted function, if we
5 // have to throw an exception, or if we might have to invoke the
6 // OnNativeCall hook for a self-hosted builtin.
7- if (!isFunction || !maybeFun->isInterpreted() ||
8+ if (true || !isFunction || !maybeFun->isInterpreted() ||
9 (construct && !maybeFun->isConstructor()) ||
10 (!construct && maybeFun->isClassConstructor()) ||
11 cx->insideDebuggerEvaluationWithOnNativeCallHook) {
12Now that all XOR operations are actually captured, the operands can be used to extract the decryption and encryption key. To do so, the ticket_on_xor()
function was added to ticket.cpp
, which sends the operands to the ticket_handle_encryption()
and ticket_handle_decryption()
functions:
1void ticket_on_xor(ticket_state_t *state, uint32_t a, uint32_t b) {
2 if (state->done) {
3 return;
4 }
5
6 // we only care about first two rounds of encryption
7 bool encrypt_done = state->current_encrypt_round_index >= ENCRYPT_ROUND_COUNT;
8 // we only get the first fourteen rounds of decryption with this method
9 bool decrypt_done = state->current_decrypt_round_index >= (DECRYPT_ROUND_COUNT - 1);
10
11 if (!encrypt_done) {
12 encrypt_done = ticket_handle_encryption(state, a, b);
13 }
14
15 if (!decrypt_done) {
16 decrypt_done = ticket_handle_decryption(state, a, b);
17 }
18
19 if (encrypt_done && decrypt_done) {
20 ticket_on_decryption_found(state);
21 state->done = 1;
22 }
23}
24Extract the Decryption Key
Using the captured XOR operands, the decryption key can be extracted. To do this, the known input state->current_decrypt_round
is compared with one of the operands and if it matches, the other operand is assumed to be the expanded key for the current round 8. After the expanded key for a round has been found, the input for the next round is computed and the process repeats until all the capturable rounds have been extracted.
1void ticket_handle_decryption(ticket_state_t *state, uint32_t a, uint32_t b) {
2 bool found_complete_round = false;
3
4 for (int i = 0; i < AES_BLOCK_SIZE; i++) {
5 if (a == state->current_decrypt_round[i]) {
6 // prepare for next round
7 state->current_decrypt_round[i] ^= b;
8
9 state->decrypt_key.round_keys[state->current_decrypt_round_index][i] = b;
10
11 if (i == (AES_BLOCK_SIZE - 1)) {
12 found_complete_round = true;
13 }
14 }
15 }
16
17 if (!found_complete_round) {
18 return false;
19 }
20
21 std::cout << "found decrypt round" << std::endl;
22
23 uint32_t previous_decrypt_round[AES_BLOCK_SIZE];
24
25 memcpy(previous_decrypt_round, state->current_decrypt_round, AES_BLOCK_SIZE_BYTES);
26
27 for (int i = 0; i < AES_BLOCK_SIZE; i++) {
28 state->current_decrypt_round[i] = calculate_decrypt(previous_decrypt_round, i);
29 }
30
31 state->current_decrypt_round_index++;
32
33 return state->current_decrypt_round_index >= (DECRYPT_ROUND_COUNT - 1);
34}
35Once all of the capturable round keys have been extracted, the ticket_on_decryption_found()
function is executed. Crucially, this function calls ticket_find_last_decryption_round_key()
which is used to extract the final round key for the decryption. After this, the round keys are converted to the original decryption key and is finally stored in state->decrypt_key_bytes
. This field is then used as described in the Setting JS Value From CPP section.
1void ticket_on_decryption_found(ticket_state_t *state) {
2 std::cout << "ticket_on_decryption_found" << std::endl;
3
4 ticket_find_last_decryption_round_key(state);
5
6 uint32_t decrypt_key[8];
7 decrypt_rounds_to_key(&state->decrypt_key, decrypt_key);
8
9 for (int i = 0; i < 8; i++) {
10 state->decrypt_key_bytes[AES_BLOCK_SIZE * i + 0] = BYTE0(decrypt_key[i]);
11 state->decrypt_key_bytes[AES_BLOCK_SIZE * i + 1] = BYTE1(decrypt_key[i]);
12 state->decrypt_key_bytes[AES_BLOCK_SIZE * i + 2] = BYTE2(decrypt_key[i]);
13 state->decrypt_key_bytes[AES_BLOCK_SIZE * i + 3] = BYTE3(decrypt_key[i]);
14 }
15
16 std::cout << "Decryption Key: [" << std::hex;
17 for (int i = 0; i < 32; i++) {
18 std::cout << "0x" << (int) state->decrypt_key_bytes[i];
19 if (i != 31) {
20 std::cout << ", ";
21 }
22 }
23 std::cout << "]" << std::endl;
24}
25As previously mentioned, the last decryption round key must be extracted in a unique way, since the round is calculated byte-by-byte so the XOR operands are all too small to reliably differentiate between other XOR operations. To calculate the last round, the decryption payload is decrypted with all of the known decryption round keys (partial_decrypt_result
) and is compared with the final result of the decryption (which is now known to be the input to the encrypt function state->encrypt_payload
). By using the values from before and after the round has been applied, it's possible to determine the round key used. Also, the decryption uses the CBC (Cipher Block Chaining) mode of operation, so the decryption IV must also be accounted for.
1void ticket_find_last_decryption_round_key(ticket_state_t *state) {
2 std::cout << "ticket_find_last_decryption_round_key" << std::endl;
3 uint32_t partial_decrypt_result[AES_BLOCK_SIZE];
4 decrypt_with_round_keys(state->decrypt_payload, &state->decrypt_key, partial_decrypt_result);
5
6 for (int i = 0; i < AES_BLOCK_SIZE; i++) {
7 state->decrypt_key.round_keys[DECRYPT_ROUND_COUNT - 1][i] =
8 partial_decrypt_result[i] ^ state->encrypt_payload[i] ^ state->decrypt_iv[i];
9 }
10}
11Last Round Explanation
The procedure for extracting the last decryption round can be illustrated by considering one byte of the last round (this is from aes-js):
1result[4 * i] = (Si[(t[i] >> 24) & 0xff] ^ (tt >> 24)) & 0xff;Note that Si
is the inverse S-box substitution table (as described in the AES specification), so the Si[t]
is the decryption progress so far (what we call partial_decrypt_result
), and tt
is the decryption key for this round.
Then, the result
is XOR'd with the decryption IV in the ModeOfOperationCBC: decrypt function (this is from
aes-js):
1plaintext[i + j] = block[j] ^ this._lastCipherblock[j];Where block
is just the result
from this current block and this._lastCipherblock
is initialized to the IV 9. This plaintext is then used as the first input to
encrypt
which is known because it is captured in Extract Encryption Input. Putting this all together, the following formula is created:
1partial_decrypt_result ^ round_key ^ decrypt_iv = plaintextWhere everything is known except for round_key
. This formula can be rearranged to calculate the round_key
:
1round_key = partial_decrypt_result ^ plaintext ^ decrypt_ivRearranging XOR Equations
For those of you that are not too familiar with XOR, this rearrangement may seem like magic. To better explain this, consider this example where the variables have been renamed to be shorter to be more easily manipulated:
1A ^ B ^ C = DWhere A
, C
, and D
are the known values and B
is the unknown value that we're trying to determine. Something important to know about XOR is that it's commutative, meaning that A ^ B = B ^ A
. This formula can be rearranged by XOR-ing both sides by A ^ C
:
1 A ^ B ^ C = D
2A ^ C ^ A ^ B ^ C = D ^ A ^ C
3A ^ A ^ B ^ C ^ C = D ^ A ^ C
4Now, it may look like all that we've done is make the formula much more complicated but it can actually now be simplified. Another important property of XOR is that XOR using the same value for both operands equals zero, meaning that X ^ X = 0
. So, the formula can be simplified as:
1A ^ A ^ B ^ C ^ C = D ^ A ^ C
2 0 ^ B ^ 0 = D ^ A ^ C
3Additionally, XOR with one operand X
and the other 0
has the result X
, meaning that X ^ 0 = X
.
10 ^ B ^ 0 = D ^ A ^ C
2 B = D ^ A ^ C
3Now the value of B
can be calculated using the known values D
, A
, and C
.
Setting JS Value From CPP
To obtain the extracted decryption and encryption keys, the modified firefox browser is used to set two arrays that are accessible from JavaScript. To do so, the following function was added which stores the specified buffer using the specified name:
1void set_array(JSContext* cx, const char *name, uint8_t buf[32]) {
2 JS::Rooted<JSObject*> arr(cx, JS_NewUint8ClampedArray(cx, 32));
3
4 JS::AutoCheckCannotGC nogc;
5 bool isShared;
6 uint8_t* data = JS_GetUint8ClampedArrayData(arr, &isShared, nogc);
7 memcpy(data, buf, 32);
8
9 JS::RootedValue arrVal(cx, JS::ObjectValue(*arr));
10
11 JS_DefineProperty(cx, cx->global(), name, arrVal, 0);
12}
13This function can then be used when both keys are extracted to store them in encryptKey
and decryptKey
:
1if (state.done) {
2 set_array(cx, "encryptKey", state.encrypt_key_bytes);
3 set_array(cx, "decryptKey", state.decrypt_key_bytes);
4
5 // prepare to run again
6 state.done = 0;
7 ticket::ticket_init(&state);
8}
9After this is done, these values are manually accessible from JavaScript in the browser or by using automation software such as Playwright:
1await page.evaluate(() => {
2 const keyChecker = setInterval(() => {
3 if (window.decryptKey && window.encryptKey) {
4 console.log("Found the keys: " + window.decryptKey + " " + window.encryptKey);
5 clearInterval(keyChecker);
6 }
7 }, 100);
8});
9Chapter 3: Extract the Encryption Key
Now that the encryption payload and IV are known, the encryption key can be extracted using a very similar procedure to that described in the Extract the Decryption Key section. Note that instead of calculating each next round using calculate_decrypt()
, each round is now calculated using calculate_encrypt()
and must be stored in a temporary buffer (new_encrypt_round
).
Intercept Crypto::GetRandomValues
The ticket anti-bot invokes the Crypto: getRandomValues() function to generate an IV for the encryption. As expected, this function is implemented in the
GetRandomValues()
function in Crypto.cpp
. This function was changed to always use a hard-coded IV so that it would always be known by the key extraction logic:``` --- a/dom/base/Crypto.cpp +++ b/dom/base/Crypto.cpp @@ -80,6 +80,19 @@ void Crypto::GetRandomValues(JSContext* aCx, const ArrayBufferView& aArray, aRv.Throw(NS_ERROR_DOM_OPERATION_ERR); return; } + + uint8_t encrypt_iv[] = { + 0xCA, 0xFE, 0xBA, 0xBE, + 0xDE, 0xAD, 0xBE, 0xEF, + 0x12, 0x34, 0x56, 0x78, + 0x9A, 0xBC, 0xDE, 0xF0 + }; + + if (dataLen >= 16) { + for (int i = 0; i < 16; i++) { + buf[i] = encrypt_iv[i]; + } + } // Copy random bytes to ABV. memcpy(aArray.Data(), buf, dataLen);
1
2
3### Encryption IV Initialization
4
5Now is a convenient time to initialize the ticket encryption key extraction code. This is added to the `ticket_init()`
6
7function in `ticket.cpp`
8
9:
10void ticket_init(ticket_state_t state) { / ... previous code ... */ state->current_encrypt_round_index = 0; // Initialize the current encrypt round to a placeholder value for (int i = 0; i < AES_BLOCK_SIZE; i++) { state->current_encrypt_round[i] = PLACEHOLDER_ENCRYPT_ROUND; } // From the hard-coded result of Crypto.getRandomValues() state->encrypt_iv[0] = BE_NUM(0xCA, 0xFE, 0xBA, 0xBE); state->encrypt_iv[1] = BE_NUM(0xDE, 0xAD, 0xBE, 0xEF); state->encrypt_iv[2] = BE_NUM(0x12, 0x34, 0x56, 0x78); state->encrypt_iv[3] = BE_NUM(0x9A, 0xBC, 0xDE, 0xF0); }
1
2
3### Extract Encryption Input
4
5As previously mentioned, the ticket anti-bot uses the [ Crypto: getRandomValues()](https://developer.mozilla.org/en-US/docs/Web/API/Crypto/getRandomValues) function to generate the encryption IV. Conveniently, this function is called in a function where the plaintext is passed as an argument. An example of what this might look like is the following:
6function encrypt(plaintext) { const iv = new Uint8Array(16); self.crypto.getRandomValues(iv); / encrypt plaintext with the IV using aes-js / }
1
2
3Using this structure, the call to the `getRandomValues()`
4
5function is used to determine name of the encryption function (it is obfuscated so it doesn't have an obvious name like "encrypt"). To find the name of this function, the `ticket_backtrace()`
6
7function was created which goes up the call stack to determines the function name. This function was created by 'borrowing' from a similar Firefox function and then it was changed to capture the name of the ticket encryption function. 10 It is included here to cover every aspect of the procedure.
8
9## ticket_backtrace Code
10bool ticket_backtrace(JSContext cx, const CallArgs& args) { RootedFunction fun(cx, &args.thisv().toObject().as<JSFunction>()); NonBuiltinScriptFrameIter iter(cx); while (!iter.done() && iter.isEvalFrame()) { ++iter; } if (iter.done() || !iter.isFunctionFrame()) { return true; } RootedObject caller(cx, iter.callee(cx)); if (!cx->compartment()->wrap(cx, &caller)) { return false; } { JSObject callerObj = CheckedUnwrapStatic(caller); if (!callerObj) { return true; } if (JS_IsDeadWrapper(callerObj)) { JS_ReportErrorNumberASCII(cx, GetErrorMessage, nullptr, JSMSG_DEAD_OBJECT); return false; } JSFunction callerFun = &callerObj->as<JSFunction>(); JSString name = JS_GetFunctionDisplayId(callerFun); const char nameStr; if (!name) { return false; } JS::Rooted<JSString> rootedStr(cx, name); nameStr = JS_EncodeStringToUTF8(cx, rootedStr).release(); strcpy(state.encrypt_func_name, nameStr); state.found_encrypt_func = 1; } return true; }
1
2
3Putting this all together, the following code was added to the `InternalCall()`
4
5function in `Interpreter.cpp`
6
7:
8--- a/js/src/vm/Interpreter.cpp +++ b/js/src/vm/Interpreter.cpp static bool InternalCall(JSContext cx, const AnyInvokeArgs& args, CallReason reason = CallReason::Call) { MOZ_ASSERT(args.array() + args.length() == args.end(), "must pass calling arguments to a calling attempt"); if (args.thisv().isObject()) { // We must call the thisValue hook in case we are not called from the // interpreter, where a prior bytecode has computed an appropriate // |this| already. But don't do that if fval is a DOM function. HandleValue fval = args.calleev(); + if (fval.isObject() && fval.toObject().is<JSFunction>()) { + JSFunction func = &fval.toObject().as<JSFunction>(); + JSString name = JS_GetFunctionDisplayId(func); + const char nameStr; + + if (!name) { + nameStr = "Unnamed function"; + } else { + JS::Rooted<JSString> rootedStr(cx, name); + nameStr = JS_EncodeStringToUTF8(cx, rootedStr).release(); + } + + if (!strcmp(nameStr, "getRandomValues")) { + ticket_backtrace(cx, args); + } + + if (state.found_encrypt_func && strcmp(nameStr, state.encrypt_func_name) == 0) { + HandleValue val = args.get(0); + + size_t length; + bool isSharedMemory; + uint8_t data; + JS_GetObjectAsUint8Array(&val.toObject(), &length, &isSharedMemory, &data); + + uint32_t payload[AES_BLOCK_SIZE_BYTES]; + for (uint32_t i = 0; i < AES_BLOCK_SIZE; i++) { + payload[i] = BE_NUM( + data[AES_BLOCK_SIZE i + 0], + data[AES_BLOCK_SIZE i + 1], + data[AES_BLOCK_SIZE i + 2], + data[AES_BLOCK_SIZE i + 3] + ); + } + + ticket_set_encrypt_payload(&state, (uint8_t *) payload, AES_BLOCK_SIZE_BYTES); + } + }
1
2
3In this code, the `ticket_backtrace()`
4
5function is called whenever the `getRandomValues()`
6
7function is called, which captures the name of the encryption function in `state.encrypt_func_name`
8
9. Then, when the encrypt function is known and is invoked, the plaintext payload is captured and is passed to the `ticket_set_encrypt_payload()`
10
11function. This function is implemented in `ticket.cpp`
12
13and is used to set `state->encrypt_payload`
14
15and prepare for the key extraction by calculating `state->current_encrypt_round`
16
17:
18void ticket_set_encrypt_payload(ticket_state_t state, const uint8_t payload, uint32_t payload_length) { ASSERT(payload_length == AES_BLOCK_SIZE_BYTES); memcpy(&state->encrypt_payload, payload, payload_length); for (int i = 0; i < AES_BLOCK_SIZE; i++) { state->current_encrypt_round[i] = state->encrypt_iv[i] ^ state->encrypt_payload[i]; } }
1
2
3### Extract the Encryption Key
4
5Now that the encryption payload and IV are known, the encryption key can be extracted using a very similar procedure to that described in the [Extract the Decryption Key](#extract-the-decryption-key) section. Note that instead of calculating each next round using `calculate_decrypt`
6
7, each round is now calculated using `calculate_encrypt`
8
9and must be stored in a temporary buffer (`new_encrypt_round`
10
11).
12void ticket_handle_encryption(ticket_state_t *state, uint32_t a, uint32_t b) { bool found_complete_round = false; for (int i = 0; i < AES_BLOCK_SIZE; i++) { if (a == state->current_encrypt_round[i]) { // prepare for next round state->current_encrypt_round[i] ^= b; state->encrypt_key.round_keys[state->current_encrypt_round_index][i] = b; if (i == (AES_BLOCK_SIZE - 1)) { found_complete_round = true; } } } if (!found_complete_round) { return false; } if (state->current_encrypt_round_index == 0) { uint32_t new_encrypt_round[AES_BLOCK_SIZE]; for (int i = 0; i < AES_BLOCK_SIZE; i++) { new_encrypt_round[i] = calculate_encrypt(state->current_encrypt_round, i); } for (int i = 0; i < AES_BLOCK_SIZE; i++) { state->current_encrypt_round[i] = new_encrypt_round[i]; } } state->current_encrypt_round_index++; if (state->current_encrypt_round_index >= ENCRYPT_ROUND_COUNT) { ticket_on_encryption_found(state); return true; } return false; }
1
2
3Then, once all relevant encryption round keys are found, the function `ticket_on_encryption_found()`
4
5is invoked. This function extracts the original encryption key by considering the first two round keys:
6void ticket_on_encryption_found(ticket_state_t state) { std::cout << "ticket_on_encryption_found" << std::endl; for (int i = 0; i < 8; i++) { uint32_t round_key = state->encrypt_key.round_keys[i / AES_BLOCK_SIZE][i % AES_BLOCK_SIZE]; state->encrypt_key_bytes[AES_BLOCK_SIZE i + 0] = BYTE0(round_key); state->encrypt_key_bytes[AES_BLOCK_SIZE i + 1] = BYTE1(round_key); state->encrypt_key_bytes[AES_BLOCK_SIZE i + 2] = BYTE2(round_key); state->encrypt_key_bytes[AES_BLOCK_SIZE * i + 3] = BYTE3(round_key); } std::cout << "Encryption Key: [" << std::hex; for (int i = 0; i < 32; i++) { std::cout << "0x" << (int) state->encrypt_key_bytes[i]; if (i != 31) { std::cout << ", "; } } std::cout << "]" << std::endl; }
1
2
3## Chapter 4: Retrieving the Browser Fingerprint
4
5Now that both the decryption and encryption keys have been extracted, it is time to retrieve the browser fingerprint. This step is actually the easiest because it has *already* been done. There's no need for finding all of the individual challenges and trying to somehow pass them in a non-genuine environment. A perfectly legitimate web browser 11 was used and already successfully performed all these challenges. The results of these challenges are stored in an encrypted form in the
6
7`ntbcc`
8
9cookie that was generated during the encryption stage. All that is required is to take that cookie and decrypt it using the captured encryption key:```
10const extractFingerprint = ({ encryptKey, cookies }) => {
11const cookieParts = cookies.split(';')
12.find(c => c.includes('ntbcc'))
13.trim()
14.split('=', 2);
15if (cookieParts.length !== 2) {
16console.error(`Unable to extract ntbcc cookie value from parts: ${cookieParts}`);
17return "";
18}
19const cookie = cookieParts[1];
20const cipherLength = cookie.length - 32;
21const cipher = aesjs.utils.hex.toBytes(cookie.slice(0, cipherLength));
22const iv = aesjs.utils.hex.toBytes(cookie.slice(cipherLength));
23const aesCbc = new aesjs.ModeOfOperation.cbc(encryptKey, iv);
24const decryptedBytes = aesjs.padding.pkcs7.strip(aesCbc.decrypt(cipher));
25const fingerprint = Array.from(decryptedBytes.slice(48));
26return fingerprint;
27}Conclusion
This post has documented the journey veritas and I went through to the extract the decryption and encryption keys from the supreme ticket anti-bot system. To do this, several changes and additions were made to the Firefox source code to automatically extract these keys from the ticket anti-bot system. Additionally, a valid browser fingerprint which passed all challenges was extracted by using the captured encryption key to decrypt a valid cookie.
Using the decryption key, encryption key, and valid browser fingerprint, countless valid cookies can be generated which allows for a complete bypass of the ticket anti-bot system.
Here is a link to the anti-bot for the curious
Footnotes
- The anti-bot wasn't a result of this exact release but is a perfect example of a ridiculous markup on a basic item.
https://stockx.com/supreme-clay-brick-red↩ - Using the term
ciphertextmay be a bit misleading as even after analysis, there was no obvious interpretation of theplaintext. Interpreting it as UTF-7 shows some structure but this could simply be some structure in another encoding.↩ - The plaintext of the token is 56-bytes (+8-bytes padding) while only the first 48-bytes are used in cookie and the remaining 8-bytes are discarded.
↩ - There's an additional randomly generated IV that is included in the cookie.
↩ - Due to how the expanded decryption key is calculated, only the last two expanded keys are needed to construct the original decryption key.
↩ - Due to how the expanded encryption key is calculated, only the first two expanded keys are needed to construct the original encryption key.
↩ - Please do not use ChatGPT to write your important encryption/decryption code!
↩ - This code assumes that the order of the operands have not been reversed. A better (but more complicated) solution could consider the case where these operands have been rearranged.
↩ - This is why it's crucial the procedures for decryption and encryption key extraction are used on the
firsttimedecrypt
andencrypt
are called, otherwise we'd be stuck not knowing the last cipher block.↩ - CallerGetterImpl
located insidejs/src/jsfun.cpp
↩ - At least for now. Who knows how long that will last with what Google is trying to do with Web Environment Integrity.