php中的字符串格式化漏洞
字符串格式化函数
php中有多个字符串格式化函数,分别是printf()
、sprintf()
、vsprintf()
。
1 | printf($format[, $args]) ==> int 直接将格式化的结果输出,返回值为int类型(输出字符串的长度) |
**参数format
**:
必需。规定字符串以及如何格式化其中的变量。
可能的格式值:
1 | - %% - 返回一个百分号 % |
附加的格式值。必需放置在%
和字母之间(例如%.2f
):
1 | + - 在数字前面加上 + 或 - 来定义数字的正负性。默认情况下,只有负数才做标记,正数不做标记 |
注释:如果使用多个上述的格式值,它们必须按照以上顺序使用。
举个栗子:
单个参数的格式化:
1 | # 普通格式化字符串和数字 |
多个参数的格式化:
1 | $num = 1; |
格式化字符串特性
多参数字符串格式化时,传入的参数个数不能比格式字符串中声明的参数数量少,否则将发生错误而返回false,无法进行格式化
但是下面的这种除外:
1 | var_dump(sprintf('a %1$s b %s', 'test')) ==> string(13) "a test b test" |
也就是说,格式字符串中 位置对应参数 不受 序号对应参数 的影响
前面讲到了sprintf
中可以格式化的类型,也就是**参数format
**中用来参数占位的%类型
,如果遇到无法识别的格式化类型,则会格式化失败,将其丢弃。
比如:
1 | var_dump(sprintf('a %ab','test')) ==> string(3) "a b" |
吞掉单引号
1 | # 使用#填充,最后长度为10 |
再看看这个:
1 | var_dump(sprintf("%1$'%s", 'test')) ==> string(4) "test" |
由于%'
无法被识别,所以%1$'
将会被丢弃,剩余的%s
进行格式化,结果为为test
吞掉单引号的漏洞常常发生在sql语句中,例如:
1 | function prepare($query, $args) { |
函数prepare()
会去掉格式化字符串%s
的单引号和双引号,同时在最后加上单引号。虽然最后加上了一个'
,但是我们还是有办法能够逃脱这个单引号.
利用%1$'%s
之前讲到sprintf("%1$'%s", 'test')
会丢弃%1$'
从而吞掉其中的单引号,利用这一点,我们可以传入
1 | $username=%1$%s or 1=1# |
这样经过$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 | $query = '1 %s 2'; |
可以看到,这里的%s
通过两次添加的单引号而逃逸了出来
这里如果设置
1 | $username = ['admin', 'or 1=1#']; |
则经过$a = prepare("AND password=%s", $password)
后的$a
为AND 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 | var_dump(sprintf('%1$d->%1$c', 97)) ==> string(5) "97->a" |
因此,对于上面的例子,还可以传入:
1 | $username = '39'; |
通过$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
可控时,就可能产生漏洞,这也是利用的必要条件。