diff options
author | Wojtek Kosior <koszko@koszko.org> | 2022-05-26 14:55:16 +0200 |
---|---|---|
committer | Wojtek Kosior <koszko@koszko.org> | 2022-05-26 14:55:16 +0200 |
commit | 8bdb22d2ca4545ebc6bc39eb3f2447cdbcafd324 (patch) | |
tree | 3944c446971ca6b900ab072122fdcdee580db733 /captcha-child-bframe.js | |
download | hacktcha-8bdb22d2ca4545ebc6bc39eb3f2447cdbcafd324.tar.gz hacktcha-8bdb22d2ca4545ebc6bc39eb3f2447cdbcafd324.zip |
initial commit
Diffstat (limited to 'captcha-child-bframe.js')
-rw-r--r-- | captcha-child-bframe.js | 735 |
1 files changed, 735 insertions, 0 deletions
diff --git a/captcha-child-bframe.js b/captcha-child-bframe.js new file mode 100644 index 0000000..f113850 --- /dev/null +++ b/captcha-child-bframe.js @@ -0,0 +1,735 @@ +/* SPDX-License-Identifier: GPL-3.0-or-later + * + * Part of Hacktcha, a free/libre front-end for reCAPTCHA for use with Haketilo. + * + * Copyright (C) 2022 Wojtek Kosior + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * As additional permission under GNU GPL version 3 section 7, you + * may distribute forms of that code without the copy of the GNU + * GPL normally required by section 4, provided you include this + * license notice and, in case of non-source distribution, a URL + * through which recipients can access the Corresponding Source. + * If you modify file(s) with this exception, you may extend this + * exception to your version of the file(s), but you are not + * obligated to do so. If you do not wish to do so, delete this + * exception statement from your version. + * + * As a special exception to the GPL, any HTML file which merely + * makes function calls to this code, and for that purpose + * includes it by reference shall be deemed a separate work for + * copyright law purposes. If you modify this code, you may extend + * this exception to your version of the code, but you are not + * obligated to do so. If you do not wish to do so, delete this + * exception statement from your version. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see <https://www.gnu.org/licenses/>. + * + * + * I, Wojtek Kosior, thereby promise not to sue for violation of this file's + * license. Although I request that you do not make use of this code in a + * proprietary program, I am not going to enforce this in court. + */ + +/* Use with https://google.com/recaptcha/api2/bframe */ + +/* Load captcha-strings.js first. */ + +"use strict"; + +/* We substitute the original HTML document for this one. */ +const replacement_markup = ` +<!DOCTYPE html> +<html> + <head> + <style> + body { + margin: 0; + background-color: white; + } + + #tiles_table, #tiles_body td { + border: 1px solid white; + padding: 0; + } + + #tiles_table { + width: 300px; + height: 300px; + border-spacing: unset; + margin: auto; + } + + div#main_tiles_challenge { + text-align: center; + } + + button#select_done_but { + margin: 3px; + } + + .hide { + display: none; + } + + .disabled-image { + opacity: 0.5; + pointer-events: none; + } + + #tiles_body td.marked-tile { + border-color: black; + opacity: 0.5; + } + </style> + </head> + <body> + <div id="main_loading"> + <h4> + Loading Hacktcha - a libre client for reCAPTCHA. + </h4> + </div> + <div id="main_timed_out" class="hide"> + <h4> + Challenge timed out. + </h4> + <button id="resume_but">Resume</button> + </div> + <div id="main_challenge_blocked" class="hide"> + <h4> + Your browser got blocked by Google :( + </h4> + <p> + You may consider clearing your cookies and connecting from a different + IP address. + </p> + <button class="retry_but">Reload Hacktcha</button> + </div> + <div id="main_error" class="hide"> + <h4> + En error occured when communicating with Google reCAPTCHA servers :( + </h4> + <p> + You can look into the JavaScript console for additional information + about the error. On major browsers the console is found in developer + tools opened with Ctrl+Shift+C. + </p> + <button class="retry_but">Retry</button> + </div> + <div id="main_tiles_challenge" class="hide"> + <h4 id="goal_head"></h4> + <table id="tiles_table"> + <tbody id="tiles_body"></tbody> + </table> + <button id="select_done_but">Done</button> + </div> + </body> +</html> +`; + +/* How much time the user has to solve a challenge. */ +const DEFAULT_TIMEOUT_MILLIS = 1000 * 120; + +/* Convenience functions. */ +function add_child(elem, node_type) { + const child = document.createElement(node_type); + elem.appendChild(child); + + return child; +} + +function range(n) { + return Array.from({length: n}, (_, i) => i); +} + +async function* hacktcha_iterate(iterable, make_api_helper_cb) { + try { + for await (const item of iterable) { + yield item; + } + } catch(e) { + if (e.hacktcha_error) + e.api_helper = make_api_helper_cb(); + + throw e; + } +} + +function rc_base64(data) { + return btoa(data) + .replaceAll("+", "-") + .replaceAll("/", "_") + .replaceAll("=", "."); +} + +const rc_json_prefix = ")]}'"; + +function rc_json_parse(text) { + if (text.startsWith(rc_json_prefix)) + text = text.substring(rc_json_prefix.length); + + return JSON.parse(text); +} + +/* Unhide one of the div's beneath body while hiding all the other ones. */ +function show_main_view(id) { + for (const div of document.querySelectorAll("body>div[id^=main_]")) + div.classList.add("hide"); + + document.getElementById(id).classList.remove("hide"); +} + +/* + * For each challenge there is a goal text provided like "Select all squares + * with stairs.". + */ +function show_goal(goal) { + goal_head.textContent = goal; +} + +/* + * At the beginning of a challenge reCAPTCHA image tiles are served as one + * super-image. The tiles are displayed by putting this super-image in cell of + * a table and positioning it so that only the relevant square is visible in + * each tile. + * + * The show_img() function displays all the tiles of the image and then + * asynchronously yields tile positions clicked by the user. + */ +let resolve_img_click = () => {}; + +async function* show_img(img_url, row_count, col_count, timeout) { + const tbody = document.getElementById("tiles_body"); + + tbody.innerHTML = ` +<style> +#tiles_body td>div { + width: ${300 / col_count - 2}px; + height: ${300 / row_count - 2}px; + overflow: hidden; +} + +#tiles_body img { + position: relative; + width: ${col_count * 100}%; + height: ${row_count * 100}%; +} + +#tiles_body img.standalone-image { + width: 100%; + height: 100%; +} +</style> +`; + + for (const row of range(row_count)) { + const tr = add_child(tbody, "tr"); + + for (const col of range(col_count)) { + const td = add_child(tr, "td"); + const img_container = add_child(td, "div"); + const img = add_child(img_container, "img"); + + td.id = `tile_${row}_${col}`; + img.src = img_url; + img.draggable = false; + + if (row) + img.style.top = String(-row * 100) + "%"; + + if (col) + img.style.left = String(-col * 100) + "%"; + + img.addEventListener("click", () => resolve_img_click([row, col])); + } + } + + let reject; + + document.getElementById("select_done_but").onclick = + () => resolve_img_click("done"); + + setTimeout(() => reject({hacktcha_error: "timed_out"}), timeout); + + while (true) { + const prom = new Promise((...cbs) => [resolve_img_click, reject] = cbs); + const value = await prom; + + if (value === "done") + return; + + yield value; + } +} + +function remove_tile_img(row, col) { + document.querySelector(`#tile_${row}_${col}>div`).innerHTML = ""; +} + +function mark_tile_img(row, col) { + document.getElementById(`tile_${row}_${col}`).classList.add("marked-tile"); +} + +function unmark_tile_img(row, col) { + document.getElementById(`tile_${row}_${col}`) + .classList.remove("marked-tile"); +} + +function set_tile_img(img_url, row, col) { + const img_container = document.querySelector(`#tile_${row}_${col}>div`); + img_container.innerHTML = ""; + + const img = add_child(img_container, "img"); + img.classList.add("standalone-image"); + img.src = img_url; + + img.addEventListener("click", () => resolve_img_click([row, col])); + + return img; +} + +async function slowly_enable_img(img) { + img.classList.add("disabled-image"); + + await new Promise(cb => setTimeout(cb, 4500)); + + img.classList.remove("disabled-image"); +} + +/* + * reCAPTCHA uses protocol buffers for part of communication. Unfortunately, + * protobuf libraries for JS are problematic to package properly. Here we + * instead craft valid protobuf messages for reCAPTCHA API "by hand". + * + * Also see: https://developers.google.com/protocol-buffers/ + */ +function protobuf_encode_varint(n) { + const out = []; + + if (n < 0) + throw new Error("'n' must be nonnegative."); + + while (true) { + const b = n & 127; + n >>= 7; + + if (n > 0){ + out.push(b | 128); + } else { + out.push(b); + break; + } + } + + return out; +} + +function protobuf_craft_message(data) { + const encoder = new TextEncoder(); + const byte_arrays = []; + + /* Note: We're not sending fields 3, 5, and 16. */ + for (const [num, key] of [ + [1, "rc_version"], + [2, "token"], + [6, "reason"], + [14, "site_key"] + ]) { + console.warn("data", key, data[key]); + const value_bin = encoder.encode(data[key]); + + /* Wire type of 2 indicates a length-delimited field. */ + byte_arrays.push(protobuf_encode_varint((num << 3) | 2)); + byte_arrays.push(protobuf_encode_varint(value_bin.length)); + byte_arrays.push(value_bin); + } + + const msg_len = byte_arrays.reduce((acc, array) => acc + array.length, 0); + const msg = new Uint8Array(msg_len); + let len_filled = 0; + + for (const array of byte_arrays) { + msg.set(array, len_filled); + len_filled += array.length; + } + + return msg; +} + +/* + * Define APIHelper type which holds some recaptcha session parameters and + * facilitates crafting common parts of reCAPTCHA AJAX requests. + * + * Each APIHelper instance should be supplied (through either of its + * constructor's arguments) with at least the following parameters: rc_version, + * token and site_key. + */ +function APIHelper(params, old_api_helper={}) { + Object.assign(this, old_api_helper); + Object.assign(this, params); +} + +const api_keys = { + "site_key": "k", + "rc_version": "v", + "p_param": "p", + "token": "c", + "image_id": "id" +}; + +APIHelper.prototype.make_api_url = function(endpoint, base_param_names=[], + extra_params={}) { + const base_url = "https://google.com/recaptcha/api2/"; + const endpoint_url = new URL(endpoint, base_url); + + const search_params = new URLSearchParams(); + + const base_param_pairs = base_param_names + .map(n => [api_keys[n], this[n]]) + .filter(([k, v]) => v !== undefined); + const param_pairs = [...base_param_pairs, ...Object.entries(extra_params)]; + + endpoint_url.search = new URLSearchParams(param_pairs); + + return endpoint_url; +} + +APIHelper.prototype.make_first_img_url = function() { + return this.make_api_url("payload", ["p_param", "site_key"]); +} + +APIHelper.prototype.make_subsequent_img_url = function() { + return this.make_api_url("payload", ["p_param", "site_key", "image_id"]); +} + +APIHelper.prototype.make_reload_message = function() { + return protobuf_craft_message({ + rc_version: this.rc_version, + token: this.token, + reason: "fi", + site_key: this.site_key + }); +} + +APIHelper.prototype.make_post_data = function(param_names) { + const param_pairs = param_names.map(name => [api_keys[name], this[name]]); + + return new URLSearchParams(param_pairs); +} + +function update_api_helper_rresp(api_helper, rresp) { + const updated_params = {token: rresp[1], p_param: rresp[9]}; + + return new APIHelper(updated_params, api_helper); +} + +function update_api_helper_dresp(api_helper, dresp) { + console.debug("updating with dresp", dresp); + const updated_params = { + token: dresp[1], + p_param: dresp[5], + image_id: (dresp[2] || [null])[0] + }; + + console.debug("after:", new APIHelper(updated_params, api_helper)); + + return new APIHelper(updated_params, api_helper); +} + +const challenge_handlers = {}; + +async function ajax_replaceimage(api_helper, partial_solution) { + const post_data = api_helper.make_post_data(["rc_version", "token"]); + post_data.append("ds", JSON.stringify(partial_solution)); + + const api_url = api_helper.make_api_url("replaceimage", ["site_key"]); + console.debug("replaceimage url", api_url, api_helper); + + const response = await fetch(api_url, { + method: "POST", + body: post_data + }); + + const dresp = rc_json_parse(await response.text()); + + api_helper = update_api_helper_dresp(api_helper, dresp); + + return {api_helper, dresp}; +} + +let tile_challenge_not_ready_counter; + +function tile_challenge_reset() { + tile_challenge_not_ready_counter = 0; + document.getElementById("select_done_but").removeAttribute("disabled"); +} + +function tile_challenge_not_ready() { + tile_challenge_not_ready_counter++; + document.getElementById("select_done_but").setAttribute("disabled", ""); +} + +function tile_challenge_ready() { + if (--tile_challenge_not_ready_counter == 0) + document.getElementById("select_done_but").removeAttribute("disabled"); +} + +function make_captcha_goal(meta) { + const to_select = rc_goal_strings[meta[0]] || rc_goal_strings[meta[6]]; + + if (to_select) { + return `Select all squares with: "${to_select}".`; + } else { + console.warn("Cannot deduce goal", meta); + + return `Cannot decude this challenge's goal :(`; + } +} + +challenge_handlers.dynamic = async function(api_helper, pmeta) { + const meta = pmeta.slice(1).filter(e => Array.isArray(e))[0]; + if (!meta) + throw new Error("Could not extract challenge metadata.", pmeta); + + show_main_view("main_tiles_challenge"); + tile_challenge_reset(); + + show_goal(make_captcha_goal(meta)); + + const selection = []; + + const row_count = meta[3]; + const col_count = meta[4]; + const tile_count = row_count * col_count; + const img_index_map = range(tile_count); + let latest_index = tile_count - 1; + + const initial_img_url = api_helper.make_first_img_url(); + const clicks = show_img(initial_img_url, row_count, col_count, + DEFAULT_TIMEOUT_MILLIS); + + for await (const click of hacktcha_iterate(clicks, () => api_helper)) { + console.debug("clicked", click); + + remove_tile_img(...click); + + const tile_index = click[0] * col_count + click[1]; + const img_index = img_index_map[tile_index]; + + selection.push(img_index); + + img_index_map[tile_index] = ++latest_index; + + tile_challenge_not_ready(); + var {api_helper} = await ajax_replaceimage(api_helper, [img_index]); + + const img_url = api_helper.make_subsequent_img_url(); + const img = set_tile_img(img_url, ...click); + + slowly_enable_img(img) + .then(tile_challenge_ready); + } + + return {api_helper, solution: selection}; +} + +challenge_handlers.multicaptcha = async function(api_helper, pmeta) { + try { + var metas = pmeta.slice(5).filter(e => Array.isArray(e))[0][0]; + } catch(e) { + throw new Error("Could not extract challenge metadata.", pmeta); + } + + console.debug("multicaptcha metas", metas); + + show_main_view("main_tiles_challenge"); + tile_challenge_reset(); + + const selections = [], start_time = Date.now(); + let first = true; + + while (metas.length > 0) { + const meta = metas.splice(0, 1)[0]; + console.debug("multicaptcha current meta", meta); + const row_count = meta[3], col_count = meta[4]; + + show_goal(make_captcha_goal(meta)); + + const clicked_imgs = new Set(); + + const img_url = first ? + api_helper.make_first_img_url() : + api_helper.make_subsequent_img_url(); + const timeout = DEFAULT_TIMEOUT_MILLIS - (Date.now() - start_time); + const clicks = show_img(img_url, row_count, col_count, timeout); + + for await (const click of hacktcha_iterate(clicks, () => api_helper)) { + console.debug("clicked", click); + + const clicked_id = click[0] * col_count + click[1]; + + if (clicked_imgs.has(clicked_id)) { + clicked_imgs.delete(clicked_id); + unmark_tile_img(...click); + } else { + clicked_imgs.add(clicked_id); + mark_tile_img(...click); + } + } + + if (first) { + api_helper = new APIHelper({image_id: "2"}, api_helper); + first = false; + } else { + tile_challenge_not_ready(); + var {api_helper} = await ajax_replaceimage(api_helper, selections); + tile_challenge_ready(); + } + + selections.push([...clicked_imgs].sort((a, b) => a - b)); + } + + return {api_helper, solution: selections}; +} + +challenge_handlers.doscaptcha = function(api_helper, pmeta) { + throw {hacktcha_error: "challenge_blocked"}; +} + +challenge_handlers.default = challenge_handlers.doscaptcha; + +async function handle_challenge(api_helper, rresp) { + console.debug("token", rresp[1]); + + const pmeta = rresp[4]; + console.debug("pmeta", pmeta); + + const challenge_type = rresp[5]; + console.debug("challenge type", challenge_type); + + console.debug("p_param", rresp[9]); + + if (!challenge_handlers.hasOwnProperty(challenge_type)) + throw new Error("Unsupported challenge type", challenge_type); + + return challenge_handlers[challenge_type](api_helper, pmeta); +} + +async function ajax_reload(api_helper) { + const url = api_helper.make_api_url("reload", ["site_key"]); + + console.debug("POST", url, api_helper.make_reload_message()); + + const response = await fetch(url, { + method: "POST", + body: api_helper.make_reload_message(), + headers: {"Content-Type": "application/x-protobuffer"} + }); + + const rresp = rc_json_parse(await response.text()); + + api_helper = update_api_helper_rresp(api_helper, rresp); + + return {api_helper, rresp}; +} + +async function run_challenges(api_helper, rresp) { + var start_time = Date.now(); + + while (true) { + var {api_helper, solution} = await handle_challenge(api_helper, rresp); + + /* + * We're omitting some less important values that we don't know how + * to produce. + */ + const post_data = api_helper.make_post_data(["rc_version", "token"]); + + const client_response_text = JSON.stringify({response: solution}); + post_data.append("response", rc_base64(client_response_text)); + + const time = String(Date.now() - start_time); + post_data.append("t", time); + post_data.append("ct", time); + + const api_url = api_helper.make_api_url("userverify", ["site_key"]); + const response = await fetch(api_url, { + method: "POST", + body: post_data + }); + + const uvresp = rc_json_parse(await response.text()); + + rresp = uvresp.filter(e => Array.isArray(e) && e[0] === "rresp")[0]; + + if (rresp) + api_helper = update_api_helper_rresp(api_helper, rresp); + else if (typeof uvresp[1] === "string") + return uvresp[1]; + else + throw new Error("No token and no 'rresp' in userverify response."); + } +} + +async function get_final_token(site_key, rc_version, token) { + var api_helper = new APIHelper({site_key, rc_version, token}); + + while (true) { + try { + var {api_helper, rresp} = await ajax_reload(api_helper); + + return await run_challenges(api_helper, rresp); + } catch(e) { + if (e.hacktcha_error === "timed_out") { + api_helper = e.api_helper; + + show_main_view("main_timed_out"); + + const resume_but = document.getElementById("resume_but"); + await new Promise(cb => resume_but.onclick = cb); + } else if (e.hacktcha_error === "challenge_blocked") { + show_main_view("main_challenge_blocked"); + + return new Promise(() => {}); + } else { + show_main_view("main_error"); + + throw e; + } + } + } +} + +async function main() { + const rc_params = new URLSearchParams(window.location.search); + const site_key = rc_params.get("k"); + const rc_version = rc_params.get("v"); + + const smuggled_json = decodeURIComponent(window.location.hash.substring(1)); + const {initial_token, origin} = JSON.parse(smuggled_json); + + document.open(); + document.write(replacement_markup); + document.close(); + + for (const but of document.getElementsByClassName("retry_but")) + but.onclick = () => window.postMessage("recreate_frame", origin); + + const final_token = + await get_final_token(site_key, rc_version, initial_token); + + console.info("got grecaptcha_token", final_token); + window.parent.postMessage(final_token, origin); +} + +main(); |