A solver for the guns.lol WebAssembly script, which is used to record views on profile pages.
If you know anything about WASM or C, please help the project by contributing!
Requires Go.
- Download the source code
- Build it with
go build - Run it with
./guns-solver -username <username>
Static = values that never change based on the same challenge, public salt, and nonce.
Dynamic = values that change on EACH solve, whatever the input is.
- WASM result hasn't been reverse engineered yet. It produces a 5-character hexadecimal string. The issue is that some hashes use this value as input.
- 5 of 5 static hashes in the payload have been reverse engineered!
- 0 of 2 dynamic hashes in the payload have been reverse engineered.
- Issue is how to find out what the input is, since it changes on each solve.
- For the moment, I just make a SHA-256 hash of a
challenge + public salt + nonce + wasm result + unix timestampstring. - In the C code, crypto functions like
crypto.randomFillSyncandcrypto.getRandomValuesare used, which are probably the reason why the output changes on each solve.
See Payload construction for more details.
This section explains how guns.lol's WebAssembly script works and how the solver interacts with it.
When you load a profile page, a server-side script which loads another one is called (see Worker script below).
Here the first script is also interesting because it contains some data which is used to construct the view recording payload.
A web worker is created with the following script:
import init, { GunsSolver } from 'https://assets.guns.lol/pkg/_gunslolpow.js';
self.onmessage = async function (event) {
const { ps, d, c, _n, org_ts } = event.data;
await init();
const _get_s = new GunsSolver(ps, c, parseInt(d), false, _n);
const ts = Math.floor(Date.now() / 1000);
const _tsoff = ts - Number(org_ts);
const tsp = ts - _tsoff;
const _res = await _get_s.solve_pow();
if (_res !== null) {
self.postMessage({ _res, ps, tsp, _n, c });
}
};init() initializes the WebAssembly module.
Then, a worker.postMessage is called with an object that has the following fields:
ps: public saltd: difficultyc: challenge_n: nonceorg_ts: original timestamp?
The worker then creates a new GunsSolver instance with the parameters and calls its solve_pow method, which returns a result _res.
Finally, it sends back an object containing _res, ps, tsp, _n, and c.
worker.onmessage = async function (event) {
const { _res, ps, tsp, _n, c } = event.data;
if (_res !== undefined) {
await getResult(_res);
}
};The main script listens for messages from the worker and calls getResult with _res when it receives a message (the rest is not used). getResult is defined in a Next.js chunk (see below).
This script fetches a WebAssembly module (written in Rust) from https://assets.guns.lol/pkg/_gunslolpow_bg.wasm and instantiates it.
Also, it exports the GunsSolver class, used in the worker script (see above).
It's interesting to look at the GunsSolver class and its constructor. It takes the following parameters:
public_salt(string)challenge(string)difficulty(number)numeric(boolean)nonce(string)
class GunsSolver {
constructor(public_salt, challenge, difficulty, numeric, nonce) {
const ptr0 = passStringToWasm0(public_salt, wasm.__wbindgen_malloc, wasm.__wbindgen_realloc);
const len0 = WASM_VECTOR_LEN;
const ptr1 = passStringToWasm0(challenge, wasm.__wbindgen_malloc, wasm.__wbindgen_realloc);
const len1 = WASM_VECTOR_LEN;
const ptr2 = passStringToWasm0(nonce, wasm.__wbindgen_malloc, wasm.__wbindgen_realloc);
const len2 = WASM_VECTOR_LEN;
const ret = wasm.gunssolver_new(ptr0, len0, ptr1, len1, difficulty, numeric, ptr2, len2);
this.__wbg_ptr = ret >>> 0;
GunsSolverFinalization.register(this, this.__wbg_ptr, this);
return this;
}
}The constructor sends the parameters to the WebAssembly module with passStringToWasm0() for the salt, challenge, and nonce to be converted to pointers.
The WebAssembly module has been converted to C using wasm2c, which made it easier to understand how the PoW challenge is solved. See the assets directory for the generated C code and their header files.
It has also been converted to WAT using wasm2wat, which is easier to read than raw WebAssembly, but still not as easy as C.
Request payload is constructed in the following way:
{
"_t": "turnstile response token",
"_c": "challenge",
"_ps": "base64 of public salt",
"_ts": "calculated timestamp",
"_s": {
"_s": "wasm solver result (5 chars [a-f0-9])",
"n": "nonce",
"ps": "public salt (same as _ps but not base64)"
},
"__hcm": {
"_s": "wasm solver result",
"_": "sha-256 hash, idk what it's for",
"_2": "another sha-256 hash, idk what it's for either",
"__meta": {
// the values here never change, they're always 5x "64" and 1x "5"
"maybe hex?": 64,
"maybe hex?": 64,
"maybe hex?": 64,
"maybe hex?": 64,
"maybe hex?": 64,
"maybe hex?": 5
},
// order doesn't matter, keys are always different on each solve anyway
"random string 1": "sha-256 hash of: public salt + challenge",
"random string 2": "sha-256 hash of: nonce + challenge",
"random string 3": "sha-256 hash of: nonce + public salt",
"random string 4": "sha-256 hash of: wasm result + challenge",
"random string 5": "sha-256 hash of: wasm result + challenge + public salt + nonce",
"random string 6": "5-chr"
},
"username": "profile username",
"deviceType": "device type: desktop, tablet, mobile",
"event": "view",
"linkId": null,
"referrer": "document.referrer"
}I tested the WASM in local with the same public salt, challenge, and nonce, and here's my findings:
random string xare ALWAYS different on each solve, but their values are not (see in the JSON above).__metakeys are ALWAYS different on each solve, but their values are CONSTANT (5x "64" and 1x "5").__hcm._sis ALWAYS the same. The same parameter is also in_s._s.__hcm._and__hcm._2ALWAYS produce different values on each solve. Their purpose is unknown.
Keys which have an unknown purpose needs to be reverse engineered.
_t: Turnstile response token, obtained from the Turnstile widget on the profile page._c: Challenge, obtained from the server-side script._ps: Base64-encoded public salt, obtained from the server-side script._ts: Calculated timestamp. In fact, it's just theorg_tsvalue sent to the worker._s: An object containing:_s: The result from the WebAssembly solver (5 characters long, hexadecimal).n: Nonce, obtained from the server-side script.ps: Public salt (same as_psbut not base64-encoded).
__hcm: An object containing:_s: The result from the WebAssembly solver (same as in_s._s)._: A SHA-256 hash (purpose unknown). Changes on each solve._2: Another SHA-256 hash (purpose unknown). Changes on each solve.__meta: An object with constant values (always five 64s and one 5).- Six random strings as keys, each containing a SHA-256 hash:
- One is the hash of
public salt + challenge. - One is the hash of
nonce + challenge. - One is the hash of
nonce + public salt. - One is the hash of
wasm result + challenge. - One is the hash of
wasm result + challenge + public salt + nonce - One is always the string "5-chr".
- One is the hash of
username: The profile username.deviceType: The type of device (e.g., desktop, tablet, mobile).event: The event type, always "view".linkId: Always null.referrer: The document referrer.
This payload is sent to POST https://guns.lol/api/analytics/record to record the view.
The WebAssembly module was written in Rust and compiled to WASM using wasm-bindgen.
It was built with Rust 1.85.0.
The original Rust source code is obviously not available, but the generated C code from wasm2c is in the assets directory.
Libraries used: