From 9d82be9d89cac6cd262927a90404c31a42548da1 Mon Sep 17 00:00:00 2001 From: Bnyro Date: Fri, 11 Sep 2026 13:54:50 +0200 Subject: [PATCH] [feat] utils.py: add altcha captcha solver --- searx/utils.py | 32 ++++++++++++++++++++++++++++++++ tests/unit/test_utils.py | 24 ++++++++++++++++++++++-- 2 files changed, 54 insertions(+), 2 deletions(-) diff --git a/searx/utils.py b/searx/utils.py index b03f8f057..44e0229bc 100644 --- a/searx/utils.py +++ b/searx/utils.py @@ -1,6 +1,7 @@ # SPDX-License-Identifier: AGPL-3.0-or-later """Utility functions for the engines""" +from hashlib import pbkdf2_hmac import time import re @@ -811,3 +812,34 @@ def format_duration(duration: str | int) -> str: if length.tm_hour: return time.strftime("%H:%M:%S", length) return time.strftime("%M:%S", length) + + +def _array_startswith(arr: bytes, prefix: bytes) -> bool: + return arr[: len(prefix)] == prefix + + +def solve_altcha(parameters: dict[str, t.Any], maxCounter: int = 1000) -> tuple[str, int] | None: + """Solves Altcha CAPTCHAs. It derives keys using PBKDF2 until the derived + key starts with the ``keyPrefix``` from the challenge. If the solver does not + finish after ``maxCounter`` iterations, returns ``None``.""" + + nonce = bytes.fromhex(parameters["nonce"]) + salt = bytes.fromhex(parameters["salt"]) + keyPrefix = bytes.fromhex(parameters["keyPrefix"]) + cost = parameters["cost"] + keyLength = parameters["keyLength"] + # e.g. "PBKDF2/SHA-256" -> "sha256" + hashAlgorithm = parameters["algorithm"].split("/")[-1].replace("-", "").lower() + + counter = 0 + while counter < maxCounter: + # Appends the counter to the nonce, i.e. writes the counter as a big-endian 32-bit integer. + secret = nonce + counter.to_bytes(length=4) + + key = pbkdf2_hmac(hash_name=hashAlgorithm, password=secret, salt=salt, iterations=cost, dklen=keyLength) + if _array_startswith(key, keyPrefix): + return key.hex(), counter + + counter += 1 + + return None diff --git a/tests/unit/test_utils.py b/tests/unit/test_utils.py index fc30ac33a..beb794417 100644 --- a/tests/unit/test_utils.py +++ b/tests/unit/test_utils.py @@ -17,7 +17,6 @@ def random_string(length, choices=string.ascii_letters): class TestUtils(SearxTestCase): - def test_gen_useragent(self): self.assertIsInstance(utils.gen_useragent(), str) self.assertIsNotNone(utils.gen_useragent()) @@ -116,7 +115,6 @@ class TestUtils(SearxTestCase): class TestXPathUtils(SearxTestCase): # pylint: disable=missing-class-docstring - TEST_DOC = """