sys32.dev
blogtags
© 2025–2026 · sys32.devblog@sys32.dev
sys32.dev/blog/how-i-found-aes-keys-inside-hcaptchas-webassembly

on this page

  • What You See in DevTools
  • The First Clue Was The Number 480
  • Reading the Key
  • The Memory is "encrypted"
  • Three Numbers That Give It Away
  • Writing the Round Keys
  • Tracing It Back to the Rust Source
  • How to Do This Yourself in Chrome DevTools
  • Step 1: Find the function.
  • Step 2: Trigger the code.
  • Step 3: Read the raw key.

How I Found AES Keys Inside hCaptcha's WebAssembly

Research into hCaptcha's client-side WebAssembly.

hera·May 18, 2026·7 min readreverse engineering

Finding AES Keys Inside hCaptcha's WebAssembly

I was reverse engineering hCaptcha's hsw.js when I recognized the output of its encrypted data. The block size, the structure, it looked like AES. Before I even looked at their WebAssembly I used some Rust AES libraries. Compiled them to WebAssembly. That is when I saw 480 at the top of the schedule. I searched for the thing in hCaptchas WebAssembly and it was right there.

What You See in DevTools

When you open a .wasm file in Chrome DevTools the browser shows you a version of the code. All the variable names are gone, replaced with things like $var2 and $var9. Function names become $func187 and $func420.

The First Clue Was The Number 480

The first thing in the function I was looking at was this:

global.get $global0
i32.const 480
i32.sub
local.tee $var2
global.set $global0
i32.const 0
local.set $var9
local.get $var2
i32.const -64
i32.sub
i32.const 0
i32.const 416
call $func335
drop

480 is important because of where it comes from. The aes library stores an AES-256 schedule as 120 u32 values. 120 Times 4 bytes gives you 480. I had already seen this when I compiled the library myself so when I searched hCaptchas WebAssembly and it came up that was enough to know I was looking at the thing even though hCaptchas was more obfuscated.

Reading the Key

After the setup the function reads the input key in pieces:

local.get $var1
i32.const 12
call $func187
...
local.get $var1
i32.const 8
call $func187
...
local.get $var1
i32.const 4
call $func187
...
local.get $var1
i32.const 0
call $func187

And then again at positions 16, 20 24 and 28. Eight reads total, each 4 bytes apart adding up to 32 bytes. 32 Bytes is 256 bits. That is the size of an AES-256 key. Here the variable $var1 holds the address of the key that was passed into the function.

The Memory is "encrypted"

WebAssembly files just read and write memory normally but hsw.js wraps all of that in custom functions that hide what is actually going on.

Those reads do not just grab a value from memory. They go through $func187 which's a custom function that scrambles and unscrambles data:

(func $func187 (param $var0 i32) (param $var1 i32) (result i32)
  local.get $var0
  local.get $var1
  i32.add
  local.tee $var1
  i32.const 320
  i32.div_u
  ...
  local.get $var1
  i32.const 96
  i32.rem_u
  i32.const 852
  i32.add
  i64.load align=1
  i32.wrap_i64
  i32.xor
)

Instead of just reading a value, $func187 figures out where in memory the data lives using a row and position calculation checks a flag then picks between two possible locations and mixes the result with another value using XOR. Writes go through $func420 which does the thing in reverse mixing the value before storing it.

This means all the data in memory looks like noise if you just read it directly. The real values only appear after going through these wrapper functions. This is a trick used in obfuscated WebAssembly to make it harder to understand what the code is doing. The data is all there just scrambled.

Three Numbers That Give It Away

After reading the key the function does a block of math using the same three numbers over and over:

sh
1431655765  which is 0x55555555 in hex
858993459   which is 0x33333333 in hex
252645135   which is 0x0F0F0F0F in hex

Here is one example of how they show up:

local.get $var3
local.get $var12
i32.xor
local.tee $var7
local.get $var4
local.get $var13
i32.xor
local.tee $var19
i32.const 2
i32.shr_u
i32.xor
i32.const 858993459
i32.and
local.set $var10

These three numbers together with bit shifts of 1, 2 and 4 are a well known pattern. They rearrange which bits are in which positions across 32-bit numbers. You almost never see all three together in code. When you do it is always a specific type of AES called fixsliced AES.

Normal AES works by looking values up in a table. The problem with that is the time it takes depends on which parts of the table get read, which can leak information to someone watching carefully. Fixsliced AES gets rid of the tables completely. Does everything with simple bit operations that always take the same amount of time no matter what the key or data is. The bit rearranging you see here is how it sets the key up for that.

Writing the Round Keys

After the bit rearranging the function writes 16 values into the 480 byte space using $func420:

local.get $var2
i32.const 28
...
call $func420
local.get $var2
i32.const 60
...
call $func420

Writes at positions 0, 4, 8, 12, 16, 20, 24, 28, 32, 36, 40, 44, 48, 52, 56, 60. That is 64 bytes total the chunk of the key schedule. Then at the end:

i32.const 64
local.set $var5
i32.const 8
local.set $var3
i32.const 2
local.set $var4
br $label6

This sets up a loop to keep going until all 480 bytes are filled with the full set of round keys.

Tracing It Back to the Rust Source

I was already using the aes library. Once I knew it was fixsliced AES in 32-bit mode I went straight to fixslice32.rs. WebAssembly can't touch hardware AES instructions so the library always ends up in the software fallback, which's that file. The key schedule function in there has let mut rkeys = [0u32; 120]. 120 Times 4 is 480. Bitslice uses exactly those three constants.

pub(crate) fn aes256_key_schedule(key: &[u8; 32]) -> FixsliceKeys256 {
    let mut rkeys = [0u32; 120];
    bitslice(&mut rkeys[..8], &key[..16], &key[16..]);
    // ...
}

120 values at 4 bytes each is 480 bytes and bitslice in that file uses those three constants. Everything lines up.

You can read the source yourself here: fixslice32.rs

How to Do This Yourself in Chrome DevTools

You can follow all of this using Chrome DevTools, no extra software needed.

Open DevTools with F12. Go to the Sources tab. Find the .wasm file in the list on the left and click it. Chrome shows you a version of the code.

Step 1: Find the function.

Press Ctrl+F and search for 480. You are looking for this exact pattern:

i32.const 480
i32.sub

With i32.const -64 showing up 7 lines below it. When you see all three you found the AES-256 setup function. Click the line number to i32.const 480 to set a breakpoint there. It should turn blue when it is set.

Step 2: Trigger the code.

Now click on the hCaptcha solve button. The page will freeze when it hits your breakpoint. You should see the code highlighted at the line you picked.

Step 3: Read the raw key.

Look at the Scope panel on the side. You will see a list of variables. You are looking for the parameter of the function, which is whatever variable gets used right after the prologue in lines like this:

local.get $var1
i32.const 12
call $func187

It is the variable that gets passed to the read function along with an offset. In this example it is $var1. In your WebAssembly it might be called something different like $var0 or $p0. Find that variable in the Scope panel. Note the number next to it. That number is where the key lives in memory before anything has been done to it.

Open the Console tab while still paused and paste this in:

const mem = new Uint8Array(wasmInstance.exports.memory.buffer);
const keyPtr = /* paste the key pointer variable value from Scope here */;
const key = Array.from(mem.slice(keyPtr, keyPtr + 32)).map(b => b.toString(16).padStart(2, '0')).join('');
console.log('key:', key);

You should see something like this printed out:

sh
key: 3a4f2c1d8e7b6a5f9c0d1e2f3b4a5c6d7e8f9a0b1c2d3e4f5a6b7c8d9e0f1a2b

That's 64 hex characters, which is 32 bytes, which is your AES-256 key.

Note that the memory is scrambled so reading it directly will give you garbage.

The shortcut for finding this hCaptcha's HSW

These three lines always mean you found the AES-256 key setup function:

i32.const 480
i32.sub
i32.const -64

The function numbers and variable names change every build but these numbers never change.


If you have questions or want to talk about this stuff, reach out at hera@sys32.dev

← all posts