Reverse Engineering the Funcaptcha (ArkoseLab Captcha)

2026-04-02

Funcaptcha is a CAPTCHA system developed by Arkose Labs and used in GitHub's sign-up flow. It has an extremely bizarre risk-control system and some truly obnoxious image challenges, and has frustrated a great many legitimate users.

funcaptcha.png

This short write-up records my exploration of Funcaptcha and the process of building a solver.

Initial Exploration

Tracing all requests after signing up, we can see that the first captcha-related request goes to the domain octocaptcha.com.

https://octocaptcha.com?origin_page=github_signup_redesign&responsive=true&require_ack=true&version=2&style_theme=light&datadome_js_tag=true&data=%7B%22email_address%22%3A%22cubk80@gmail.com%22%2C%22login%22%3A%22CaobiCaobiCaobi123%22%7D

Besides some basic information, this request also passes in the user's email address and username. Later testing showed that this is used to set the difficulty based on your email's reputation: a major provider such as Gmail lowers the difficulty, while an obscure provider or an easily abused email address raises it accordingly.

The request returns a captcha block:

html
<body style="margin: 0px">
  <div id="funcaptcha"
       data-target-origin="https://github.com"
       data-origin-page="github_signup_redesign"
       data-require-ack="true"
       data-version="2"
       data-data-exchange-payload="some encrypted content"
       data-style-theme="light"
       data-language="en"
  >
</div>

The script block above it references api.js, which is the core of Funcaptcha.

1.png

Open this URL and you can see api.js. The deobfuscator I developed with LLM assistance is published as a Gist. (Of course, it doesn't deobfuscate particularly well, just enough to be readable; the Renamer, for example, certainly can't be undone.)

2.png

The obfuscated api.js

Settings Request

After api.js loads, the first request is https://github-api.arkoselabs.com/v2/747B83EC-2CA3-43AD-A7DF-701F286FBABA/settings, which returns settings such as styles. Not very important.

json
{
  "default": {
    "settings": {
      "observability": {
        "enabled": true,
        "samplePercentage": 100
      }
    },
    "theme": {
      "container": {
        "children": {
          ".game-core-frame": {
            "style": {
              "minWidth": "215px"
            }
          }
        }
      }
    }
  }
}

PublicKey Request

The second request is a more important one.

https://github-api.arkoselabs.com/fc/gt2/public_key/747B83EC-2CA3-43AD-A7DF-701F286FBABA

The request body is form-urlencoded:

json
{
  "c": "a pile of encrypted base64",
  "public_key": "747B83EC-2CA3-43AD-A7DF-701F286FBABA",
  "site": "https://octocaptcha.com",
  "userbrowser": "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:149.0) Gecko/20100101 Firefox/149.0",
  "capi_version": "4.0.16",
  "capi_mode": "inline",
  "style_theme": "light",
  "rnd": "0.3898489811818664",
  "language": "en",
  "data[origin_page]": "github_signup_redesign",
  // data-data-exchange-payload, returned by octocaptcha.com
  "data[blob]": "a pile of encrypted base64"
}

public_key is once again 747B83EC-2CA3-43AD-A7DF-701F286FBABA, which has already shown up in many places; it is actually the sitekey. data[blob] is the raw data-data-exchange-payload returned by octocaptcha.com, used to settle things like the captcha difficulty with the server. rnd is just r("rnd", Math.random()), nothing worth looking at.

The important part is c.

3.png

c is a very long encrypted base64 string. In the code it is something called bda; tracing back a little further, we can see that bda is actually encryptedFPData, the encrypted browser fingerprint.

4.png

Back to this request: the response body is also a huge JSON object, again containing some settings:

json
{
  "token": "66018a235cc293bb9.7598136404|r=ap-southeast-1|meta=3|meta_width=300|metabgclr=transparent|metaiconclr=%23555555|guitextcolor=%23000000|lang=en|pk=747B83EC-2CA3-43AD-A7DF-701F286FBABA|dc=1|at=40|ht=1|ag=101|cdn_url=https%3A%2F%2Fgithub-api.arkoselabs.com%2Fcdn%2Ffc|surl=https%3A%2F%2Fgithub-api.arkoselabs.com|smurl=https%3A%2F%2Fgithub-api.arkoselabs.com%2Fcdn%2Ffc%2Fassets%2Fstyle-manager",
  "challenge_url": "",
  "challenge_url_sri": null,
  "challenge_url_cdn": "https://github-api.arkoselabs.com/cdn/fc/assets/ec-game-core/bootstrap/1.33.1/standard/game_core_bootstrap.js",
  "challenge_url_cdn_sri": "sha384-jRnlOzL3aNB2X9mW9oPSbNK3pX+rvMpvkvFOq6lOD1Q1OpBOB99ryDjNiCFt7SO4",
  "noscript": "Disable",
  "inject_script_integrity": null,
  "inject_script_url": null,
  "mbio": true,
  "tbio": true,
  "kbio": true,
  "styles": [
    // some SVG, CSS and similar settings
  ],
  "iframe_width": null,
  "iframe_height": null,
  "disable_default_styling": false,
  "string_table": {
    // the full set of UI strings; no idea why they're here, since the JS defines them again anyway
  },
  "compatibility_mode_enabled": true,
  "force_standard_mode": false,
  "pow": true
}

The key part is the token field. Almost every subsequent request has to carry this token field, so this request is presumably the init step, which initializes a verification session.

There is also a pow field. I tested this many times, and regardless of the IP, whether it was clean, the browser, or the operating system, pow was always true. In theory it is optional, but in practice it must be implemented.

BDA

BDA is Funcaptcha's browser fingerprinting system. This part is virtualized and executed by a stack-based virtual machine embedded in api.js.

Example:

"f76f3 a24e9"
"c942b F\ZUZC"
"a4549 '_QEXWREY\_' false"
"b51c4 1"
"c8051 L0"

The first field is the opcode, and the second is a string encrypted with XOR 103; for example, _QEXWREY\_ is navigation. Since api.js has already been deobfuscated, devirtualizing it is actually easy too: XOR all the operands back, work out roughly what each opcode does, and then have AI generate code from the bytecode.

The deobfuscated code is in the appendix.

Pow

Once pow comes back as true, a "game" is fetched from

  • https://github-api.arkoselabs.com/cdn/fc/assets/ec-game-core/bootstrap/1.33.1/standard/game_core_bootstrap.js
  • https://github-api.arkoselabs.com/cdn/fc/assets/ec-game-core/bootstrap/1.33.1/standard/pow.game_core_bootstrap.js

These are the Pow bootstrap, but they don't actually contain any important code.

The pow initialization request is https://github-api.arkoselabs.com/pows/setup?session_token=66018a235cc293bb9.7598136404.

session_token is the first segment of the token returned by the PublicKey endpoint. The response looks like this:

json
{
  "pow_token": "89818a235cc571029.8433425004",
  "url": "https://github-api.arkoselabs.com/cdn/fc/assets/pow/2.3.0/standard/index.html",
  "sequence": "https://github-api.arkoselabs.com/powseq/2vv3c40c-96b3-4f8f-bd86-49c2cec773fa/target/1141.js?Expires=1775043957&Signature=vr90yCxWQL6bCgJVJWnSpZ68WYd1jCcOcHze04XjyJ-XKSvb7bP5vgfzRVOmJJBssG6MDjRJBsqTiNuLeFkSQ1ZPgtdzsCPyVaZW1vqo4v-xRD6VCfe4j8jy8KtM9r3WAFUFv~x7UHZ2-DtIGPEue3ak9e5ptLBNCHMs1m2IOEPjT3uTIqUfVvgdEZ7vnjcJMXMCgfjCYYVfGDhgNKfAyRUsN32-F~d5xrDgJL6aIN5CnXZ1pSI4OD1smnohXmvn~L3MkJoW32vLhrW-398oTrFVtqOMIIsrY920AjqgwJaip6H~GTjThyiLQi7gpsaDEhRX6sQGK7RsqA-YUIFONA__&Key-Pair-Id=K2GG2TVX8JMCPF",
  "timeout": 75000,
  "work_type": 1,
  "transparent": false,
  "embedded_session_id_enabled": true,
  "work_config": {
    "seed": [
      {
        "Tj7yQMNW1s1T4aw7jS": "qpsc61vYR0vEJ31NTW",
        "x0ubujGiSL7hQzTowI": "EwN5JHWQUUxsluAm02"
      },
      {
        "1xneD0jMllV6xLlfsd": "PWLnG2AR5hxqm89uF5",
        "TWND0NlDc7Zng1wtKS": "3a0Y98cpXEmLAGhwKN",
        "ZHbwrxoyTnarMPvR42": "8IptmYINSPGJZ7cdUb"
      }
    ],
    "splits": [
      {
        "target_hash": "f85b5789a48ea31c20e4ab31dcfcc434d3931e3bccf59784ec3f4cc456db2ae3c1f5bc70dd883946da7449ddf8f5070c6f885ecb3c7c6b11f63875f6a47f7850"
      },
      {
        "target_hash": "19d44deeb336de2ac3cb83a18ffe296a31af3332ac6c88631d31e97b52d9c03b929e0c498cb221b1f3cee748c430d2d374f08d8d75cba4944edf181c5d9743ff"
      },
      {
        "target_hash": "66b23f6fb06444c00adb6126288d7a2176693fbcd80fc48ccd80f74992be41882c26d0d8f54e3eba30c1a35801e9ab8dcfb071c83d3d46aaa39acd86cec95f5c"
      },
      {
        "target_hash": "beeba8def17402213476f4d66f5879acde3d73cf6527d936420a328d2d9dda31acdf7277e7ea05bede238d2fb16885fdd03d081c0def64fc272676682d4830f1"
      },
      {
        "target_hash": "a30513c89daaa003d63e6aea42d51b078b6e4386475c9f3ddb9baeb959194dd6b4ceeee0b6efd69d495f245f5123b6ceb531c3429ba7909a0f4452c12fa7f33d"
      }
    ],
    "starting_nonce": 44631
  }
}

sequence is a CloudFront URL; download it and you get the Pow Worker. This JS generates a random algorithm for each request, and in my tests the transform differed slightly from request to request. The actual algorithm counts up from starting_nonce until it hits target_hash, and the lower your email reputation, the harder this challenge gets.

A deobfuscated sample is in the appendix.

Now look at the submission request https://github-api.arkoselabs.com/pows/check:

json
{
  "pow_token": "89818a235cc571029.8433425004",
  "session_token": "66018a235cc293bb9.7598136404",
  "hash_rate": 134.36469864698648,
  "execution_time": 3252,
  "transform": [
    {
      "Tj7yQMNW1s1T4aw7jS": "?5\t\u0002\u000e\r\\{!?\\\t\u000e\u000f\r]\f\u0011",
      "x0ubujGiSL7hQzTowI": "\\\r\t{7_]]\u000e\u000e6\\\u0003[\u001d\u0013(\\",
      "Tj7yQMNW1s1T4aw7jSd": "4:",
      "x0ubujGiSL7hQzTowId": "8:",
      "Tj7yQMNW1s1T4aw7jS25": ":\u0003\u0006\f\u001d",
      "x0ubujGiSL7hQzTowI49": "\n3(!\u0019\u0003D",
      "Tj7yQMNW1s1T4aw7jSd25": ":\u0003\u0006\f\u001d",
      // ...
    },
    {
      "0": "=@=9;9",
      "1": "@=8:8?",
      "2": "@=>::9",
      "3": "<=:;<8",
      "4": "=@=<@9",
      "0d": "8",
      "1d": "8",
      "2d": "8",
      "3d": "8",
      "4d": "8"
    }
  ],
  "result": [
    {
      "target_hash": "f85b5789a48ea31c20e4ab31dcfcc434d3931e3bccf59784ec3f4cc456db2ae3c1f5bc70dd883946da7449ddf8f5070c6f885ecb3c7c6b11f63875f6a47f7850",
      "attempt_count": 131589
    },
    {
      "target_hash": "19d44deeb336de2ac3cb83a18ffe296a31af3332ac6c88631d31e97b52d9c03b929e0c498cb221b1f3cee748c430d2d374f08d8d75cba4944edf181c5d9743ff",
      "attempt_count": 207890
    },
    {
      "target_hash": "66b23f6fb06444c00adb6126288d7a2176693fbcd80fc48ccd80f74992be41882c26d0d8f54e3eba30c1a35801e9ab8dcfb071c83d3d46aaa39acd86cec95f5c",
      "attempt_count": 221856
    },
    {
      "target_hash": "beeba8def17402213476f4d66f5879acde3d73cf6527d936420a328d2d9dda31acdf7277e7ea05bede238d2fb16885fdd03d081c0def64fc272676682d4830f1",
      "attempt_count": 340452
    },
    {
      "target_hash": "a30513c89daaa003d63e6aea42d51b078b6e4386475c9f3ddb9baeb959194dd6b4ceeee0b6efd69d495f245f5123b6ceb531c3429ba7909a0f4452c12fa7f33d",
      "attempt_count": 481585
    }
  ]
}

result is easy to understand: it is the number of attempts at which each target was hit. transform is the junk that JS spits out. The algorithm is randomly adjusted on every request, possibly assembled at random from a very large library of algorithms, which makes this one of the biggest challenges in implementing the protocol.

The response is:

json
{"action":"challenge"}

blocked means it failed; challenge means it succeeded, but an image challenge still has to be completed. If your IP is clean enough, challenge may be skipped, and you can submit as soon as the pow is done.

Image Challenge

5.png

First request:

GET https://github-api.arkoselabs.com/fc/init-load/?session_token=66018a235cc293bb9.7598136404

It returns an empty body, and its purpose is a bit unclear; my guess is that it just helps the server switch state or something like that.

Second request:

https://github-api.arkoselabs.com/fc/gfct/

Request body:

token=66018a235cc293bb9.7598136404&sid=ap-southeast-1&render_type=canvas&lang=en&isAudioGame=false&is_compatibility_mode=false&apiBreakerVersion=green&analytics_tier=40

sid is also part of the token returned by the PublicKey endpoint, and isAudioGame depends on whether you chose Visual Puzzle or Audio Puzzle before the challenge started.

The response body contains a lot:

json
{
  "session_token": "66018a235cc293bb9.7598136404",
  "challengeID": "25318a235cd359d91.7162892704",
  "challengeURL": "https://github-api.arkoselabs.com/fc/assets/match-game-ui/0.33.0/standard/index.html",
  "sec": 80,
  "game_data": {
    "customGUI": {
      "example_images": {
        "key": "https://github-api.arkoselabs.com/cdn/fc/assets/game4failureexamples/3dRollball_v2/prod_key1.png",
        "answer": "https://github-api.arkoselabs.com/cdn/fc/assets/game4failureexamples/3dRollball_v2/prod_ans1.png"
      },
      "timed_mode": 1,
      "encrypted_mode": 1,
      "_challenge_imgs": [ // the first image
        "https://github-api.arkoselabs.com/rtig/image?challenge=0&expires=1775043961&gameToken=25318a235cd359d91.7162892704&sessionToken=66018a235cc293bb9.7598136404&signature=ESJDjJ8wTyIMo4XCPg2F2yCiw0bEfDoaZYs4XxR85G4ZdvFsOfgI3%2BU1dE2MXVDS9azXMMRKeWhMwE%2BIvD1KjmlR4wIG4Inlv21hLzPp64wmMaxY8zaRJLvirJoDepT3br%2FeVw572pr1zjq9XAEI5qjgiml%2FSJfuob2QZqWMcVO3fPhPazaZz9dEhsqeGt8YSa5dN90MMv9eWr07q29NXGQoU7wNOG3Br8yd4GstyMU5lXllgC1iZFXrl8SSbzKAkfEh1S0rfWRrhAmFhSP%2BhXPCaNvqLb55cKo5LxNEwrXFaopgjkQihO7veblVKDmRXcHNnV577kRBwlq0ZhDzQQ%3D%3D"
      ],
      "audio_disabled": false,
      "embedded_session_id_enabled": 1,
      "_guiTheme": 3,
      "_progress_prop": {
        "bgColor": "#f7f7f7"
      },
      "fc2_well_done_image": "/fc/assets/graphics/ecbase/StraightTickGrey.svg",
      "_meta_bg_colour": "transparent",
      "_guiTextColor": "#000000",
      "_meta_icon_colour": "#555555"
    },
    "waves": 8,
    "gameType": 4,
    "game_difficulty": 8,
    "instruction_string": "3DRollball_v2_var1",
    "answer_height": 200,
    "answer_width": 200,
    "key_height": 200,
    "key_width": 125
  },
  "sid": "ap-southeast-1",
  "lang": "en",
  "string_table_prefixes": [
    "github_v3_custom_instructions"
  ],
  "string_table": {
    "4.challenge_progress": "{{currentChallenge}} of {{numChallenges}}",
    "4.fail_button": "Try again",
    // the UI string table again
  },
  "style_theme": "light",
  "dapib_url": "https://github-api.arkoselabs.com/dapib/ap-southeast-1/45cb63bd-789a-417a-91a4-1eb62a7ceb2c/1126.js?expiry=1775042161575&mac=tpJLRXihg2JTEDoexWMrXNyN96ohlZlmKD30%2BM6oKQY%3D"
}

The first image is encrypted. To see how to decrypt it, look at the next request:

https://github-api.arkoselabs.com/fc/ekey/

Request body:

session_token=66018a235cc293bb9.7598136404&game_token=25318a235cd359d91.7162892704&sid=ap-southeast-1

It returns this data:

json
{
  "time_end": 1775040483,
  "time_end_seconds": 119,
  "decryption_key": "60718a235cd539e16.0582037704"
}

decryption_key is the evpKDF key; use it to decrypt the first image.

6.jpg 7.jpg

Examples of decrypted images

dapib_url is another script that generates a random algorithm on every request; it is used to encrypt some of the content of later requests. A deobfuscated sample is in the appendix.

Submitting the Image Challenge

Let's analyze the submission request https://github-api.arkoselabs.com/fc/ca/.

Request body (it is actually URL-encoded, but for readability I used Reqable's automatic conversion to JSON here):

json
{
  "session_token": "66018a235cc293bb9.7598136404",
  "game_token": "25318a235cd359d91.7162892704",
  "sid": "ap-southeast-1",
  "guess": "{\"ct\":\"fHNN5WGY8Th65XS+6gxbRw==\",\"iv\":\"a2591693d0768a45441531bccf099440\",\"s\":\"a8a6e40a5f584d60\"}",
  "render_type": "canvas",
  "analytics_tier": "40",
  "bio": "a base64 string",
  "is_compatibility_mode": "false",
  "ecdata": "eyJoZWlnaHQiOjQ1MCwid2lkdGgiOjQwMH0=",
  "tguess": "{\"ct\":\"omv2t3M2+8gcMLa/xU2A3SN5q3VmTR3SuZIsKvwaoHnoSRd6+jcroS88WF/vkxp2RtO4QMdagxUzkjMCRmMfoH6ItCTKegqp6ARbwCo9M1VDEmpWS1rmgekP01CLc0gLGWz2kHMfgL2VAPfJbN2R5z641fchgVWcUTwschuW5j6m1tSYxUv2I3NcIuibcjof\",\"iv\":\"4d729246e83888d3289f3db8de914acb\",\"s\":\"9ff42baa374f5bc4\"}"
}

There is also an x-requested-id request header: {"ct":"ahjI1ohU02G9DaoF8oD2MFNuVHUKtCjt/a0RmZ+Hzhw=","iv":"0a89d68bd18600afcd70b4b289303a86","s":"9909436dc9561ef8"}

guess is the answer chain. Example plaintext: [{"index":3},{"index":1},...]. It is encrypted with evpKDF, using the SessionToken as the key.

The index says which image in the first column matches the second column.

tguess is guess encrypted with DAPIB, that is, with the method from the script at dapib_url in the previous request.

x-requested-id is the plaintext {"sc":[random string,random string]}, encrypted with evpKDF using "REQUESTED" + session_token + "ID" as the key.

bio is mouse-trajectory data, but in my testing this data isn't actually subjected to any sophisticated validation; stuffing in a pile of random data is enough.

8.png

The response after submitting is:

json
{
  "response": "not answered",
  "solved": null,
  "incorrect_guess": null,
  "decryption_key": "70018a235cd39f498.7848585704",
  "time_end": 1775040489,
  "time_end_seconds": 119,
  "_challenge_imgs": [
    "https://github-api.arkoselabs.com/rtig/image?challenge=1&expires=1775043961&gameToken=25318a235cd359d91.7162892704&sessionToken=66018a235cc293bb9.7598136404&signature=RKOO2S3MdoJ7n0alrjxdC8IOUC9V07U5MHkLcK63eygHtj4BflJx1RS2VtvRCGhgKbFJRs4Pd4KtZRuR5nQuT5fhfW9Mwv1KY%2BFeNH0FEvq%2B1UlV47iBb6yonHwXVWIpVZd99pF0koP1VlaleFgRFKl6vzXGZWyvvyu7wp8do2rS7BOTZ0DLiEIc316yoYroHXyhUEdYkMDYR60OeW%2FsstbMcKujVGbOtqxDNEEggaoeh2NvDkZoVp5Y2DLetq2tm7%2FhTbp14Xk8whleULtUvV0Kuo8BaiAmJncHg7WoqoWoxaWY145bPp%2BKKcQ4S%2FjsaXvWm4ru8RfLAPns9bmgDw%3D%3D"
  ]
}

The response contains the decryption_key and URL of the next image; just use decryption_key to decrypt the next image.

After you answer the last image, it returns:

json
{
  "response": "answered",
  "solved": true,
  "incorrect_guess": null,
  "decryption_key": null,
  "time_end": null,
  "time_end_seconds": null,
  "_challenge_imgs": null
}

There is also a very odd check here: only the endpoints starting with https://github-api.arkoselabs.com/rtig/image have JA3 detection. If your TLS fingerprint isn't that of a real browser, you get flagged while downloading the images, and the server then won't accept your answers. I used the Impit library for the submission, which solved the problem.

Once you get answered, you can submit the session_token directly as the octocaptcha-token in GitHub's sign-up to complete verification.

9.png

Appendix

A Deobfuscated Pow Worker Sample

js
const HASH_STATIC = "webkitSubtle";

function makeMessage(type) {
  return {
    type: type,
    data: {}
  };
}

const TASK_TYPE = {
  START: "start",
  NOOP: "noop"
};

const RESULT_TYPE = {
  ERROR: "error",
  DONE: "done",
  LOADED: "loaded"
};

class Timer {
  constructor() {
    this.startTime = 0;
    this.endTime = 0;
    this.elapsed = 0;
    if (typeof performance !== "undefined") {
      this.now = () => performance.now();
    } else {
      this.now = Date.now;
    }
  }

  start() {
    this.startTime = this.now();
  }

  stop() {
    this.endTime = this.now();
    this.elapsed = this.endTime - this.startTime;
    return this.elapsed;
  }
}

try {
  onmessage = async event => {
    let msg = event.data;

    switch (msg.type) {
      case TASK_TYPE.NOOP: {
        break;
      }

      case TASK_TYPE.START: {
        try {
          const seed = msg.data;

          const result = await (async function () {
            let hashCounter = 0;

            function toBase64(str) {
              const buf = new Uint16Array(str.length);
              for (let i = 0; i < str.length; i++) {
                buf[i] = str.charCodeAt(i);
              }
              return btoa(String.fromCharCode(...new Uint8Array(buf.buffer)));
            }

            async function solveAll(seedData, chunkMeta, startingNonce, timeout) {
              const timer = new Timer();
              timer.start();
              return new Promise(async (resolve, reject) => {
                try {
                  const encoded = toBase64(JSON.stringify(seedData));
                  let chunks = splitIntoChunks(encoded, chunkMeta.length);
                  chunks = chunks.map((chunk, i) => {
                    return {
                      target: chunkMeta[i].targetHash,
                      seed: chunk
                    };
                  });
                  let solved = await solveChunks(chunks, startingNonce, timeout);
                  resolve(solved);
                } catch (err) {
                  if (err.message === "TIMEOUT") {
                    reject({
                      message: "TIMEOUT",
                      hashRate: hashCounter / timer.stop()
                    });
                  } else {
                    reject(err);
                  }
                }
              });
            }

            async function solveChunks(chunks, startingNonce, timeout) {
              let nonce = startingNonce;
              const results = [];
              const timer = new Timer();
              timer.start();
              for (let i = 0; i < chunks.length; i++) {
                const solved = await solveOne(startingNonce, chunks[i], timer, timeout);
                nonce = solved.nonce;
                results.push(solved);
              }
              const elapsed = timer.stop();
              const lastNonce = results[results.length - 1].nonce;
              const hashRateInternal = (lastNonce - startingNonce) / elapsed;
              return {
                hashRateInternal: hashRateInternal,
                results: results,
                time: Math.round(elapsed)
              };
            }

            async function solveOne(startingNonce, chunk, timer, timeout) {
              let nonce = startingNonce;
              let hash = "";
              let hashFn = sha512;
              while (hash !== chunk.target && timer.stop() < timeout) {
                hash = await hashFn(chunk.seed + nonce);
                nonce++;
                hashCounter++;
              }
              if (timer.stop() >= timeout) {
                throw new Error("TIMEOUT");
              }
              return {
                target: chunk.target,
                nonce: nonce
              };
            }

            function splitIntoChunks(str, count) {
              let size = Math.floor(str.length / count);
              str = str.substring(0, size * count);
              let chunks = [];
              for (let i = 0; i < str.length - 1; i += size) {
                if (i === count - 1) {
                  chunks.push(str.substring(i));
                } else {
                  chunks.push(str.substring(i, i + size));
                }
              }
              return chunks;
            }

            async function transformAnswers(answers) {
              let result = answers;

              function levenshtein(a, b) {
                const matrix = new Array(a.length + 1)
                  .fill(null)
                  .map(() => new Array(b.length + 1).fill(null));
                for (let i = 0; i <= a.length; i++) {
                  matrix[i][0] = i;
                }
                for (let j = 0; j <= b.length; j++) {
                  matrix[0][j] = j;
                }
                for (let i = 1; i <= a.length; i++) {
                  for (let j = 1; j <= b.length; j++) {
                    const del = matrix[i - 1][j] + 1;
                    const ins = matrix[i][j - 1] + 1;
                    const sub = matrix[i - 1][j - 1] + (a[i - 1] === b[j - 1] ? 0 : 1);
                    matrix[i][j] = Math.min(del, ins, sub);
                  }
                }
                return matrix[a.length][b.length];
              }

              function alphabet() {
                return Array.from(Array(26)).map((_, i) => i + 0x41).map(String.fromCharCode);
              }

              result = result.map(answer => {
                const out = answer;
                Object.keys(answer).forEach(key => {
                  const value = answer[key];
                  const offset = levenshtein(btoa(encodeURIComponent(value)), alphabet());
                  const shifted = value.split("").map(ch => {
                    const code = ch.charCodeAt(0) + offset;
                    if (code > 0x7e) return code - 0x41;
                    return code;
                  });
                  out[key] = shifted.map(c => String.fromCharCode(c)).join("");
                  out[key + "d"] = offset.toString();
                });
                return out;
              });

              result = result.map(answer => {
                const out = answer;
                Object.keys(answer).forEach(key => {
                  const value = key[0];
                });
                return out;
              });

              result = result.map(answer => {
                const out = answer;
                Object.keys(answer).forEach(key => {
                  const value = answer[key];
                  const half = Math.ceil(value.length / 2);
                  const first = value.slice(0, half);
                  const second = value.slice(half, value.length);
                  out[key] = second + first;
                });
                return out;
              });

              const shiftTable = [
                0x4e, 0x2a, 0x11, 0x5b, 0x3, 0x45, 0x3a, 0x48, 0x1d, 0x37, 0x62, 0x54,
                0x26, 0x57, 0xa, 0x33, 0x3f, 0x6, 0x21, 0xc, 0x28, 0x2, 0x4b, 0x19,
                0x5f, 0x32, 0x3d, 0x44, 0x25, 0xf, 0xe, 0x63, 0x2c, 0x35, 0xd, 0x5d,
                0x5c, 0x24, 0x3b, 0x9, 0x13, 0x8, 0x23, 0x1c, 0x36, 0x38, 0x2b, 0x16,
                0x42, 0x61, 0x29, 0x27
              ];

              function substitute(ch) {
                const code = ch.charCodeAt(0);
                if (code >= 0x61 && code <= 0x7a) {
                  return String.fromCharCode(shiftTable[code - 0x61]);
                }
                if (code >= 0x41 && code <= 0x5a) {
                  return String.fromCharCode(shiftTable[code - 0x27]);
                }
                return ch;
              }

              result = result.map(answer => {
                const out = answer;
                Object.keys(answer).forEach(key => {
                  const value = answer[key];
                  const chars = value.split("");
                  const transformed = chars.map(substitute).map(substitute).map(substitute);
                  out[key] = transformed.join("");
                });
                return out;
              });

              return result;
            }

            async function stringifyAndTransform(answers) {
              const stringified = answers.map(answer => {
                const out = answer;
                Object.keys(answer).forEach(key => {
                  out[key] = answer[key].toString();
                });
                return out;
              });
              let transformed = await transformAnswers(stringified);
              transformed = transformed.map(answer => {
                const out = answer;
                Object.keys(answer).forEach(key => {
                  let value = out[key];
                  out[key] = value;
                });
                return out;
              });
              return transformed;
            }

            const answers = seed.answers;
            const chunkMeta = seed.foxData;
            const startingNonce = seed.PenguinHorse;
            const timeout = seed.timeout;

            let transformedAnswers = await stringifyAndTransform(answers);
            const solved = await solveAll(transformedAnswers, chunkMeta, startingNonce, timeout);
            const nonceMap = Object.fromEntries(
              solved.results.map((r, i) => {
                return [i, r.nonce];
              })
            );
            const finalTransform = await stringifyAndTransform([...transformedAnswers, nonceMap]);

            const output = {
              hashRate: solved.hashRateInternal,
              time: solved.time,
              finalTransform: finalTransform,
              targetHashData: solved.results.map(r => {
                return {
                  iterations: r.nonce,
                  targetHash: r.target
                };
              })
            };
            return output;
          })();

          let doneMsg = makeMessage(RESULT_TYPE.DONE);
          doneMsg.data = result;
          postMessage(doneMsg);
        } catch (err) {
          let errMsg = makeMessage(RESULT_TYPE.ERROR);
          let message = "";
          if (typeof err === "object" && err) {
            message = err.message;
          }
          if (message === "TIMEOUT") {
            errMsg.data = {
              error: {
                data: {
                  hashRate: err.hashRate
                },
                type: "timeout",
                message: "done"
              }
            };
          } else {
            errMsg.data = {
              error: {
                data: message,
                trace: err.data,
                type: "runtime_error",
                message: "Error when processing work"
              }
            };
          }
          postMessage(errMsg);
        }
        break;
      }

      default: {
        let unknownMsg = makeMessage(RESULT_TYPE.ERROR);
        unknownMsg.data = {
          error: "unknown message type"
        };
        postMessage(unknownMsg);
      }
    }
  };

  const loadedMsg = makeMessage(RESULT_TYPE.LOADED);
  postMessage(loadedMsg);
} catch (err) {
  let errMsg = makeMessage(RESULT_TYPE.ERROR);
  errMsg.data = {
    error: {
      type: "init_error",
      data: err,
      message: "stringify error"
    }
  };
  postMessage(errMsg);
}

const sha512 = function () {
  function getWebkitSubtle() {
    return !!crypto && "webkitSubtle" in crypto ? crypto.webkitSubtle : null;
  }
  function getSubtle() {
    return !!crypto && crypto.subtle || getWebkitSubtle();
  }
  return async function hashStandard(data) {
    const msgBuffer = new TextEncoder().encode(data);
    const subtle = getSubtle();
    const hashBuffer = await subtle.digest("SHA-512", msgBuffer);
    const hashArray = Array.from(new Uint8Array(hashBuffer));
    const hashHex = hashArray.map(b => b.toString(16).padStart(2, "0")).join("");
    return hashHex;
  };
}();

Deobfuscated DAPIB

js
(function () {
  function sendToParent(tanswer) {
    window.parent.ae.dapibReceive({ tanswer: tanswer });
  }

  function encodeCharCode(c) {
    const i = c - 35;
    const squared = i * i;
    const shifted = squared + 17;
    const halved = shifted / 2;
    const floored = Math.floor(halved);
    const mod = floored % 101;
    return mod + 35;
  }

  function transform(answers) {
    let result = answers;

    result = result.map(obj => {
      Object.keys(obj).forEach(key => {
        const val = obj[key];
        const lenStr = val.length.toString();
        const n = lenStr.length;
        const head = lenStr.slice(0, Math.floor(n / 2));
        const tail = lenStr.slice(Math.floor(n / 2), n);
        obj[head + key + tail] = val;
        delete obj[key];
      });
      return obj;
    });

    result = result.map(obj => {
      Object.keys(obj).forEach(key => {
        const chars = obj[key].split('');
        const encoded = chars.map(ch =>
          String.fromCharCode(encodeCharCode(ch.charCodeAt(0)))
        );
        for (let i = 4; i < encoded.length; i += 5) {
          encoded.splice(i, 0, '-');
        }
        obj[key] = encoded.join('');
      });
      return obj;
    });

    result = result.map(obj => {
      Object.keys(obj).forEach(key => {
        const val = obj[key];
        const lenStr = val.length.toString();
        const n = lenStr.length;
        const head = lenStr.slice(0, Math.floor(n / 2));
        const tail = lenStr.slice(Math.floor(n / 2), n);
        obj[head + key + tail] = val;
        delete obj[key];
      });
      return obj;
    });

    result = result.map(obj => {
      Object.keys(obj).forEach(key => {
        const val = obj[key];
        const lenStr = val.length.toString();
        const n = lenStr.length;
        const head = lenStr.slice(0, Math.floor(n / 2));
        const tail = lenStr.slice(Math.floor(n / 2), n);
        obj[head + key + tail] = val;
        delete obj[key];
      });
      return obj;
    });

    return result;
  }

  function process(answers, suffix) {
    (function selfDefense() {
      return arguments.callee.toString().search('(((.+)+)+)+$');
    })();

    const stringified = answers.map(obj => {
      Object.keys(obj).forEach(key => {
        obj[key] = obj[key].toString();
      });
      return obj;
    });

    let result = transform(stringified);

    result = result.map(obj => {
      Object.keys(obj).forEach(key => {
        obj[key] = obj[key] + (suffix ? suffix : '');
      });
      return obj;
    });

    sendToParent(result);
  }

  try {
    const doc = window.document;
    let poison = undefined;

    const isNodeLike =
      Object.prototype.toString.call(
        typeof process !== 'undefined' ? process : 0
      ) === '[object process]';

    const isPrerender =
      doc.hidden &&
      doc.visibilityState === 'prerender' &&
      typeof window.requestAnimationFrame === 'undefined' &&
      typeof window.cancelAnimationFrame === 'undefined';

    const isDomAbnormal = !(doc.activeElement instanceof Object);

    if (isNodeLike || isPrerender || isDomAbnormal) {
      poison = String.fromCharCode(Math.random() * 26 + 0x41);
    }

    const answer = window.parent.ae.answer;
    process(answer, poison);
  } catch (err) {
    sendToParent(err);
  }
})();

Deobfuscated BDA

js
'use strict';

const RSA_PUBLIC_KEY_SPKI_B64 =
  "MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA8dYw7H536reZPgEFRZka62Lhgf" +
  "cJdbm5ugSL+ypixEMJq9FynLgdXUg7tEhQgM0bnfNlsiGuWjQSR/Y8Jd7n0ouV97gR4Efy" +
  "sYydmKowfwcwBlTIlvNC4crSqNpVkI09F4+S1UktEytN81dblxrNTY895rfoLy5H+IMfCk" +
  "TLuKC8j3PwTW1my6GsYv1fTVgu5vv3zhsg0nndPE/RL/BPpqUs9yR2iqIBLiT+2DnaJEx8" +
  "5bf9kOUn7FYQ6vCCJ8Mkcuzp0btOHU20PoqbjoikSzuxMaD3D40Q0TQPGzfcmhArcUJVee" +
  "+wnPHC81jOaZvPcUz3ySAfpbdFbxU4KC37nwIDAQAB";


function getNavigationEntryCount() {
  if (window.navigation) {
    if (window.navigation.entries) {
      const entries = window.navigation.entries();
      return entries.length;
    }
  }
  return null;
}

function getHeapSizeLimit() {
  const perf = window.performance;
  if (!perf) return [1, null];

  const mem = perf.memory;
  if (!mem) return [2, null];

  const limit = mem.jsHeapSizeLimit;
  if (!limit) return [3, null];

  return [0, limit];
}

function collectFingerprint(payload) {
  if (!payload || !payload.value) return;

  const navEntries = getNavigationEntryCount();
  const heapInfo = getHeapSizeLimit();

  payload.value.push({ key: "vsadsa", value: navEntries });
  payload.value.push({ key: "basfas", value: heapInfo });
  payload.value.push({ key: "lfasdgs", value: window.arkl.cbid });

  const f25 = payload.value[25];
  const f26 = payload.value[26];
  const f1 = payload.value[1];
  const f18 = payload.value[18];

  if (f25 && f1 && f18) {
    if (
      f1.value && f1.value.length > 12 &&
      f18.value && f18.value.length > 12
    ) {
      payload.value[25] = {
        key: f25.key(),
        value: f1.value.slice(0, 3) + f18.value.slice(0, 3),
      };
    } else {
      payload.value[25] = { key: f25.key(), value: "abcdef" };
    }
  }

  if (f26) {
    if (f26.value) {
      payload.value[26] = { key: f26.key(), value: f26.value * 3 };
    }
  }
}


async function encryptWebCrypto(payload, rsaPublicKeyB64, crypto) {
  if (!payload) return null;
  if (!rsaPublicKeyB64) return null;

  const plaintext = JSON.stringify(payload);

  async function generateAesKey() {
    return crypto.subtle.generateKey(
      { name: "AES-GCM", length: 256 },
      true,
      ["encrypt"]
    );
  }

  function generateIv() {
    return crypto.getRandomValues(new Uint8Array(12));
  }

  async function aesEncrypt(aesKey, iv, text) {
    const data = window.arkl.strToBytes(text);
    return crypto.subtle.encrypt(
      { name: "AES-GCM", tagLength: 128, iv: iv },
      aesKey,
      data
    );
  }

  function splitCiphertextAndTag(gcmOutput) {
    const bytes = new Uint8Array(gcmOutput);
    const TAG_LEN = 16;
    const ciphertext = new Uint8Array(bytes.subarray(0, bytes.length - TAG_LEN));
    const tag = new Uint8Array(bytes.subarray(bytes.length - TAG_LEN));
    return { ciphertextBuffer: ciphertext.buffer, tagBuffer: tag.buffer };
  }

  async function wrapAesKeyWithRsa(iv, aesKey) {
    const rawAesKey = await crypto.subtle.exportKey("raw", aesKey);

    const spkiDer = window.arkl.b64ToBytes(rsaPublicKeyB64);
    const rsaPubKey = await crypto.subtle.importKey(
      "spki",
      spkiDer,
      { name: "RSA-OAEP", hash: "SHA-256" },
      false,
      ["encrypt"]
    );

    return crypto.subtle.encrypt(
      { name: "RSA-OAEP", hash: "SHA-256" },
      rsaPubKey,
      rawAesKey
    );
  }

  const aesKey = await generateAesKey();
  const iv = generateIv();
  const gcmOut = await aesEncrypt(aesKey, iv, plaintext);
  const parts = splitCiphertextAndTag(gcmOut);
  const cipher = parts.ciphertextBuffer;
  const tag = parts.tagBuffer;
  const wrappedKey = await wrapAesKeyWithRsa(iv, aesKey);

  const cipherB64 = window.arkl.bytesToB64(cipher);
  const tagB64 = window.arkl.bytesToB64(tag);
  const ivB64 = window.arkl.bytesToB64(iv.buffer);
  const keyB64 = window.arkl.bytesToB64(wrappedKey);

  return keyB64 + ivB64 + tagB64 + cipherB64;
}


async function encryptMsrCrypto(payload, rsaPublicKeyB64, msrCrypto) {
  function seedPrng(entropySource) {
    const seed = [];
    for (let i = 0; i < entropySource.length; i++) {
      seed[i] = entropySource[i];
    }
    msrCrypto.initPrng(seed);
  }

  const entropy = new Uint8Array(48);
  window.msCrypto.getRandomValues(entropy);
  seedPrng(entropy);

  return await encryptWebCrypto(payload, rsaPublicKeyB64, msrCrypto);
}


function forgeParsePublicKey(rsaPublicKeyB64, forge) {
  const der = forge.util.decode64(rsaPublicKeyB64);
  const asn1 = forge.asn1.fromDer(der);
  return forge.pki.publicKeyFromAsn1(asn1);
}

function forgeRandomKey(forge) {
  return forge.random.getBytesSync(32);
}

function forgeRandomIv(forge) {
  return forge.random.getBytesSync(12);
}

function forgeAesEncrypt(aesKey, iv, plaintext, forge) {
  const cipher = forge.cipher.createCipher("AES-GCM", aesKey);
  cipher.start({ iv: iv });
  cipher.update(forge.util.createBuffer(plaintext, "utf8"));
  const ok = cipher.finish();
  if (!ok) return null;
  return cipher;
}

function forgeWrapAesKey(aesKey, rsaPublicKeyB64, forge) {
  const rsaPubKey = forgeParsePublicKey(rsaPublicKeyB64, forge);
  const md = forge.md.sha256.create();
  return rsaPubKey.encrypt(aesKey, "RSA-OAEP", { md: md });
}

function encryptForge(payload, rsaPublicKeyB64, forge) {
  if (!payload) return null;
  if (!forge) return null;
  if (!rsaPublicKeyB64) return null;

  const aesKey = forgeRandomKey(forge);
  const iv = forgeRandomIv(forge);

  const cipher = forgeAesEncrypt(aesKey, iv, JSON.stringify(payload), forge);
  if (!cipher) return null;

  const ciphertext = cipher.output.getBytes();
  const tag = cipher.mode.tag.getBytes();
  const wrappedKey = forgeWrapAesKey(aesKey, rsaPublicKeyB64, forge);

  const cipherB64 = forge.util.encode64(ciphertext);
  const tagB64 = forge.util.encode64(tag);
  const ivB64 = forge.util.encode64(iv);
  const keyB64 = forge.util.encode64(wrappedKey);

  return keyB64 + ivB64 + tagB64 + cipherB64;
}


async function main() {
  let result = undefined;

  const payload = window.arkl.pl;
  const rsaPublicKeyB64 = RSA_PUBLIC_KEY_SPKI_B64;

  collectFingerprint(payload);

  if (window.arkl.cryptoType === "crypto") {
    result = await encryptWebCrypto(payload, rsaPublicKeyB64, window.arkl.crypto);
  } else if (window.arkl.cryptoType === "msr") {
    result = await encryptMsrCrypto(payload, rsaPublicKeyB64, window.arkl.msrCrypto);
  } else if (window.arkl.cryptoType === "forge") {
    result = encryptForge(payload, rsaPublicKeyB64, window.arkl.forge);
  }

  window.arkl.rs = result;
}

main();

Comments

Loading comments…