36c3 的时候看了很久SaV-ls-l-aaS
这道题也没有做出来,这道题的分类是Web&Crypto,赛后看了题解感觉题的质量很高,出的很巧妙的一道题,这里记录一下。
首先捋一下流程:
60601端口开着一个Web服务,题目描述给了连接方法:
1 | url='http://78.47.240.226:60601' && ip=$(curl -s "$url/ip") && sig=$(curl -s -d "cmd=ls -l&ip=$ip" "$url/sign") && curl --data-urlencode "signature=$sig" "$url/exec" |
可以看到,先是访问 /ip
得到 ip,再向 /sign
post 过去 ip 和我们要执行的命令,得到签名,最后向 /exec
post signature 来执行命令。我们执行这一行可以发现回显了ls -l
执行的结果,发现有个 flag.txt。
看源码,Web 服务是由 go 起的:
1 | package main |
代码很容易看,限制了 cmd 只能是ls -l
,其余不给签名,看样子我们是要伪造其他命令的签名来读flag,这里注意到签名和验签的过程是传给本地起的一个 php 来完成的,看一下这部分源码:
1 |
|
采用的是md5WithRSAEncryption
的方式签名,本地试了一下,是把我们传入的 $d
md5 后转为hex,填充到0x1ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff003020300c06082a864886f70d020505000410
后面,组成数字然后用RSA签名。
看样子整个逻辑找不到一点问题,用的都是标准库,基本无法攻击。我初步的想法是通过代理更换 ip,可以拿到两个 ip|ls -l 的签名,这样我们就拥有了两组 RSA 的 m 和 c,因为题目给了 dockerfile 给了生成公私钥的方法,使用 openssl 默认生成,e为65537,那么我们可以通过求公因数的方式来求出 n。
在得到两组签名后,我们要得到 RSA 的m,就是填充后的数,所以按照代码逻辑,在 go 里面先是 sha1:
1 | msg := ip + "|" + cmd |
再 php 里的 md5,得到两组 m 和 c,但是总是求不出公因数 n,怀疑我们求的 m 不对。看代码发现 go 里把 sha1的结果用 json 编码,然后传到 php里 json 解码。这部分非常可疑,为何要用 json 编码(用 hex 传过去它不香么),所以本地搭一下环境跟一下(题目给了dockerfile)
起一个docker,改一下 index.php,加一个var_dump($d);
,再改一下 go,返回一下 php 的结果:
1 | fmt.Fprintln(w,string(body)) |
现在让程序签名,返回结果:
1 | string(38) " ��.���?-�KC��@�" |
$d 竟然是长度为 38 的字符串,看来果然是这里编码有问题,我们需要看一下每个步骤的结果,先看一下 go 里 json编码后的 sha1 结果是什么:
1 | package main |
运行一下:
1 | "\u000e\t\u001d\ufffd\u0012\ufffd.\ufffd\ufffd\ufffd?-\ufffdKC\ufffd\u0005\ufffd@\ufffd" |
和正常的sha1的结果来比较一下:
1 | Python 2.7.16 (default, Sep 2 2019, 11:59:44) |
由于 go 的 json 编码,很多不可见字符都被转为了 U+fffd
,丢失了很多信息。
再经过 php 接口的接收,我们来看一下结果:
1 | $d = json_decode(file_get_contents('php://input'), JSON_THROW_ON_ERROR); |
结果:
1 | string(89) ""\u000e\t\u001d\ufffd\u0012\ufffd.\ufffd\ufffd\ufffd?-\ufffdKC\ufffd\u0005\ufffd@\ufffd" |
U+fffd
变成了\xef\xbf\xbd
。所以由于 go 的 json 编码问题,丢失了很多信息,造成了 md5 前的数据有很多相同字符。当时做题时往下并没有细想,得到 n 后总是想构造出任意命令的签名,也很疑惑如果构造出岂不是这种签名就不安全了?其实是无法得到的。
正解是 go 的这种问题 ,为碰撞创造了条件。我们可以碰撞出在这种编码情况下与 ls -l
有相同结果的cat *
此类命令。但是问题是我们需要非常大量 ip 来提供碰撞的数据。
可以发现,go 取 ip 的时候,是先用net.ParseIP
解析了 ip,我们在 ip 每个数字前面加 0 ,解析后还是原来的 ip 结果,每个数字最多添加 256 个 0,四个数字就已经产生了 2^32
种不同的组合,足以碰撞出 ls -l
与 cat *
之间的冲突。
官方题解的 c++ 碰撞脚本:
1 | // g++ -std=c++17 -march=native -O3 -lcrypto -lpthread gewalt.cpp -o gewalt |
编译可能会找不到 lcrypto
,编译命令加上 lcrypto 路径(我本地是 /usr/local/opt/openssl/lib)
1 | g++ -std=c++17 -march=native -O3 -lcrypto -lpthread gewalt.cpp -o gewalt -L/usr/local/opt/openssl/lib |
与 go 交互的脚本:
1 | #!/usr/bin/env python3 |
1 | ⚙ SaV-ls-l-aaS python solve.py 127.0.0.1 60601 |