从WordPress SQLi谈PHP格式化字符串问题(2017.11.01更新)
作者:SeaFood@知道创宇404实验室
0x00 背景
近日,WordPress爆出了一个SQLi漏洞,漏洞发生在WP的后台上传图片的位置,通过修改图片在数据库中的参数,以及利用php的sprintf
函数的特性,在删除图片时,导致'
单引号的逃逸。漏洞利用较为困难,但思路非常值得学习。
0x01 漏洞分析
漏洞发生在wp-admin/upload.php的157行,进入删除功能,
之后进入函数wp_delete_attachment( $post_id_del )
,$post_id_del可控,而且没有做(int)格式转化处理。
wp_delete_attachment位于wp-includes\post.php
的 4863 行。其中
图片的post_id被带入查询,$wpdb->prepare中使用了sprintf,会做自动的类型转化,可以输入22 payload
,会被转化为22
,因而可以绕过。
之后进入4898行的delete_metadata( 'post', null, '_thumbnail_id', $post_id, true );
函数。
delete_metadata函数位于wp-includes\meta.php
的307行,
在这里代码拼接出了如下sql语句,meta_value为传入的media参数
1 |
<ol class="linenums"><li class="L0"><code class="lang-sql"><span class="pln">SELECT meta_id FROM wp_postmeta WHERE meta_key </span><span class="pun">=</span><span class="pln"> </span><span class="str">'_thumbnail_id'</span><span class="pln"> AND meta_value </span><span class="pun">=</span><span class="pln"> </span><span class="str">'payload'</span></code></li></ol> |
之后这条语句会进入查询,结果为真代码才能继续,所以要修改_thumbnail_id对应的meta_value的值为payload,保证有查询结果。
因此,我们需要上传一张图片,并在写文章
中设置为特色图片。
在数据库的wp_postmeta
表中可以看到,_thumbnail_id
即是特色图片设定的值,对应的meta_value即图片的post_id。
原文通过一个 WP<4.7.5 版本的xmlrpc漏洞修改_thumbnail_id
对应meta_value的值,或通过插件importer
修改。这里直接在数据库里修改,修改为我们的payload。
之后在365行,此处便是漏洞的核心,问题在于代码使用了两次sprintf
拼接语句,导致可控的payload进入了第二次的sprintf
。输入payload为22 %1$%s hello
代码会拼接出sql语句,带入$wpdb->prepare
1 |
<ol class="linenums"><li class="L0"><code class="lang-sql"><span class="pln">SELECT post_id FROM wp_postmeta WHERE meta_key </span><span class="pun">=</span><span class="pln"> </span><span class="str">'%s'</span><span class="pln"> AND meta_value </span><span class="pun">=</span><span class="pln"> </span><span class="str">'22 %1$%s hello'</span></code></li></ol> |
进入$wpdb->prepare后,代码会将所有%s
转化为'%s'
,即meta_value = '22 %1$'%s' hello'
因为sprintf的问题 (vsprintf与sprintf类似) ,'%s'
的前一个'
会被吃掉,%1$'%s
被格式化为_thumbnail_id ,最后格式化字符串出来的语句会变成
单引号成功逃逸!
最后payload为
1 |
<ol class="linenums"><li class="L0"><code><span class="pln">http</span><span class="pun">:</span><span class="com">//localhost/wp-admin/upload.php?action=delete&media[]=22%20%251%24%25s%20hello&_wpnonce=bbba5b9cd3</span></code></li></ol> |
这个SQL注入不会报错,只能使用延时注入,而且需要后台的上传权限,所以利用起来比较困难。
0x02 漏洞原理
上述WordPress的SQLi的核心问题在于在sprintf
中,'%s'
的前一个'
被吃掉了,这里利用了sprintf
的padding
功能
单引号后的一个字符会作为padding填充字符串。
此外,sprintf
函数可以使用下面这种写法
%后的数字代表第几个参数,$后代表类型。
所以,payload%1$'%s'
中的'%
被视为使用%
进行 padding,导致了'
的逃逸。
0x03 php格式化字符串
但在测试过程中,还发现其他问题。php的sprintf
或vsprintf
函数对格式化的字符类型没做检查。
如下代码是可以执行的,显然php格式化字符串中并不存在%y
类型,但php不会报错,也不会输出%y
,而是输出为空
1 |
<ol class="linenums"><li class="L0"><code class="lang-php"><span class="pun"><?</span><span class="pln">php</span></code></li><li class="L1"><code class="lang-php"><span class="pln">$query </span><span class="pun">=</span><span class="pln"> </span><span class="str">"%y"</span><span class="pun">;</span></code></li><li class="L2"><code class="lang-php"><span class="pln">$args </span><span class="pun">=</span><span class="pln"> </span><span class="str">'b'</span><span class="pun">;</span></code></li><li class="L3"><code class="lang-php"><span class="pln">echo sprintf</span><span class="pun">(</span><span class="pln"> $query</span><span class="pun">,</span><span class="pln"> $args </span><span class="pun">)</span><span class="pln"> </span><span class="pun">;</span></code></li><li class="L4"><code class="lang-php"><span class="pun">?></span></code></li></ol> |
通过fuzz得知,在php的格式化字符串中,%后的一个字符(除了'%'
)会被当作字符类型,而被吃掉,单引号'
,斜杠\
也不例外。
如果能提前将%' and 1=1#
拼接入sql语句,若存在SQLi过滤,单引号会被转义成\'
1 |
<ol class="linenums"><li class="L0"><code class="lang-sql"><span class="kwd">select</span><span class="pln"> </span><span class="pun">*</span><span class="pln"> </span><span class="kwd">from</span><span class="pln"> user </span><span class="kwd">where</span><span class="pln"> username </span><span class="pun">=</span><span class="pln"> </span><span class="str">'%\' and 1=1#'</span><span class="pun">;</span></code></li></ol> |
然后这句sql语句如果继续进入格式化字符串,\
会被%
吃掉,'
成功逃逸
1 |
<ol class="linenums"><li class="L0"><code class="lang-php"><span class="pun"><?</span><span class="pln">php</span></code></li><li class="L1"><code class="lang-php"><span class="pln">$sql </span><span class="pun">=</span><span class="pln"> </span><span class="str">"select * from user where username = '%\' and 1=1#';"</span><span class="pun">;</span></code></li><li class="L2"><code class="lang-php"><span class="pln">$args </span><span class="pun">=</span><span class="pln"> </span><span class="str">"admin"</span><span class="pun">;</span></code></li><li class="L3"><code class="lang-php"><span class="pln">echo sprintf</span><span class="pun">(</span><span class="pln"> $sql</span><span class="pun">,</span><span class="pln"> $args </span><span class="pun">)</span><span class="pln"> </span><span class="pun">;</span></code></li><li class="L4"><code class="lang-php"><span class="com">//result: select * from user where username = '' and 1=1#'</span></code></li><li class="L5"><code class="lang-php"><span class="pun">?></span></code></li></ol> |
不过这样容易遇到PHP Warning: sprintf(): Too few arguments
的报错。
还可以使用%1$
吃掉后面的斜杠,而不引起报错。
1 |
<ol class="linenums"><li class="L0"><code class="lang-php"><span class="pun"><?</span><span class="pln">php</span></code></li><li class="L1"><code class="lang-php"><span class="pln">$sql </span><span class="pun">=</span><span class="pln"> </span><span class="str">"select * from user where username = '%1$\' and 1=1#' and password='%s';"</span><span class="pun">;</span></code></li><li class="L2"><code class="lang-php"><span class="pln">$args </span><span class="pun">=</span><span class="pln"> </span><span class="str">"admin"</span><span class="pun">;</span></code></li><li class="L3"><code class="lang-php"><span class="pln">echo sprintf</span><span class="pun">(</span><span class="pln"> $sql</span><span class="pun">,</span><span class="pln"> $args</span><span class="pun">)</span><span class="pln"> </span><span class="pun">;</span></code></li><li class="L4"><code class="lang-php"><span class="com">//result: select * from user where username = '' and 1=1#' and password='admin';</span></code></li><li class="L5"><code class="lang-php"><span class="pun">?></span></code></li></ol> |
通过翻阅php的源码,在ext/standard/formatted_print.c
的642行
可以发现php的sprintf
是使用switch..case..实现,对于未知的类型default
,php未做任何处理,直接跳过,所以导致了这个问题。
在高级php代码审核技术中的5.3.5中,提及过使用$order_sn=substr($_GET["order_sn"], 1)
截断吃掉\
或"
。
之前也有过利用iconv转化字符编码,iconv('utf-8', 'gbk', $_GET['word'])
因为utf-8和gbk的长度不同而吃掉\
。
几者的问题同样出现在字符串的处理,可以导致'
的转义失败或其他问题,可以想到其他字符串处理函数可能存在类似的问题,值得去继续发掘。
0x04 利用条件
-
执行语句使用
sprintf
或vsrptinf
进行拼接 -
执行语句进行了两次拼接,第一次拼接的参数内容可控,类似如下代码
1 |
<ol class="linenums"><li class="L0"><code class="lang-php"><span class="pun"><?</span><span class="pln">php</span></code></li><li class="L1"><code class="lang-php"></code></li><li class="L2"><code class="lang-php"><span class="pln">$input </span><span class="pun">=</span><span class="pln"> addslashes</span><span class="pun">(</span><span class="str">"%1$' and 1=1#"</span><span class="pun">);</span></code></li><li class="L3"><code class="lang-php"><span class="pln">$b </span><span class="pun">=</span><span class="pln"> sprintf</span><span class="pun">(</span><span class="str">"AND b='%s'"</span><span class="pun">,</span><span class="pln"> $input</span><span class="pun">);</span></code></li><li class="L4"><code class="lang-php"><span class="pun">...</span></code></li><li class="L5"><code class="lang-php"><span class="pln">$sql </span><span class="pun">=</span><span class="pln"> sprintf</span><span class="pun">(</span><span class="str">"SELECT * FROM t WHERE a='%s' $b"</span><span class="pun">,</span><span class="pln"> </span><span class="str">'admin'</span><span class="pun">);</span></code></li><li class="L6"><code class="lang-php"><span class="pln">echo $sql</span><span class="pun">;</span></code></li><li class="L7"><code class="lang-php"><span class="com">//result: SELECT * FROM t WHERE a='admin' AND b=' ' and 1=1#'</span></code></li></ol> |
0x05 总结
此次漏洞的核心还是sprintf
的问题,同一语句的两次拼接,意味着可控的内容被带进了格式化字符串,又因为sprintf
函数的处理问题,最终导致漏洞的发生。
此问题可能仍会出现在WordPress的插件,原文的评论中也有人提到曾在Joomla中发现过类似的问题。而其他使用sprintf
进行字符串拼接的cms,同样可能因此导致SQL注入和代码执行等漏洞。
0x06 参考链接
https://medium.com/websec/wordpress-sqli-bbb2afcc8e94
https://medium.com/websec/wordpress-sqli-poc-f1827c20bf8e
http://php.net/manual/zh/function.sprintf.php
https://www.seebug.org/vuldb/ssvid-96376
——————————————————2017.11.01 更新——————————————————
0x07 WordPress 4.8.2补丁问题
国外安全研究人员Anthony Ferrara给出了另一种此漏洞的利用方式,并指出了WordPress 4.8.2补丁存在的问题。
如下代码
1 |
<ol class="linenums"><li class="L0"><code class="lang-php"><span class="pun"><?</span><span class="pln">php</span></code></li><li class="L1"><code class="lang-php"></code></li><li class="L2"><code class="lang-php"><span class="pln">$input1 </span><span class="pun">=</span><span class="pln"> </span><span class="str">'%1$c) OR 1 = 1 /*'</span><span class="pun">;</span></code></li><li class="L3"><code class="lang-php"><span class="pln">$input2 </span><span class="pun">=</span><span class="pln"> </span><span class="lit">39</span><span class="pun">;</span></code></li><li class="L4"><code class="lang-php"><span class="pln">$sql </span><span class="pun">=</span><span class="pln"> </span><span class="str">"SELECT * FROM foo WHERE bar IN ('$input1') AND baz = %s"</span><span class="pun">;</span></code></li><li class="L5"><code class="lang-php"><span class="pln">$sql </span><span class="pun">=</span><span class="pln"> sprintf</span><span class="pun">(</span><span class="pln">$sql</span><span class="pun">,</span><span class="pln"> $input2</span><span class="pun">);</span></code></li><li class="L6"><code class="lang-php"><span class="pln">echo $sql</span><span class="pun">;</span></code></li><li class="L7"><code class="lang-php"><span class="com">//result: SELECT * FROM foo WHERE bar IN ('') OR 1 = 1 /*') AND baz = 39</span></code></li></ol> |
%c
起到了类似chr()
的效果,将数字39转化为'
,从而导致了sql注入。
对此,WordPress 4.8.2补丁在WPDB::prepare()
中加入
1 |
<ol class="linenums"><li class="L0"><code class="lang-php"><span class="pln">$query </span><span class="pun">=</span><span class="pln"> preg_replace</span><span class="pun">(</span><span class="pln"> </span><span class="str">'/%(?:%|$|([^dsF]))/'</span><span class="pun">,</span><span class="pln"> </span><span class="str">'%%\\1'</span><span class="pun">,</span><span class="pln"> $query </span><span class="pun">);</span></code></li></ol> |
从而,禁用了除%d
,%s
,%F
之外的格式,这种方法导致了三个问题。
1.大量开发者在开发过程中使用了例如%1$s
的格式,此次补丁导致代码出错。
2.在例如以下代码中
1 |
<ol class="linenums"><li class="L0"><code class="lang-php"><span class="pln"> $db</span><span class="pun">-></span><span class="pln">prepare</span><span class="pun">(</span><span class="str">"SELECT * FROM foo WHERE name= '%4s' AND user_id = %d"</span><span class="pun">,</span><span class="pln"> $_GET</span><span class="pun">[</span><span class="str">'name'</span><span class="pun">],</span><span class="pln"> get_current_user_id</span><span class="pun">());</span></code></li></ol> |
%4s
会被替换成%%4s
,%%
在sprintf中代表字符%
,没有格式化功能。所以,$_GET['name']
会被写到%d
处,攻击者可以控制user id,可能导致越权问题的出现。
3.补丁可以被绕过
在meta.php
的漏洞处
1 |
<ol class="linenums"><li class="L0"><code class="lang-php"><span class="pln"> </span><span class="kwd">if</span><span class="pln"> </span><span class="pun">(</span><span class="pln"> $delete_all </span><span class="pun">)</span><span class="pln"> </span><span class="pun">{</span></code></li><li class="L1"><code class="lang-php"><span class="pln"> $value_clause </span><span class="pun">=</span><span class="pln"> </span><span class="str">''</span><span class="pun">;</span></code></li><li class="L2"><code class="lang-php"><span class="pln"> </span><span class="kwd">if</span><span class="pln"> </span><span class="pun">(</span><span class="pln"> </span><span class="str">''</span><span class="pln"> </span><span class="pun">!==</span><span class="pln"> $meta_value </span><span class="pun">&&</span><span class="pln"> </span><span class="kwd">null</span><span class="pln"> </span><span class="pun">!==</span><span class="pln"> $meta_value </span><span class="pun">&&</span><span class="pln"> </span><span class="kwd">false</span><span class="pln"> </span><span class="pun">!==</span><span class="pln"> $meta_value </span><span class="pun">)</span><span class="pln"> </span><span class="pun">{</span></code></li><li class="L3"><code class="lang-php"><span class="pln"> $value_clause </span><span class="pun">=</span><span class="pln"> $wpdb</span><span class="pun">-></span><span class="pln">prepare</span><span class="pun">(</span><span class="pln"> </span><span class="str">" AND meta_value = %s"</span><span class="pun">,</span><span class="pln"> $meta_value </span><span class="pun">);</span></code></li><li class="L4"><code class="lang-php"><span class="pln"> </span><span class="pun">}</span></code></li><li class="L5"><code class="lang-php"><span class="pln"> $object_ids </span><span class="pun">=</span><span class="pln"> $wpdb</span><span class="pun">-></span><span class="pln">get_col</span><span class="pun">(</span><span class="pln"> $wpdb</span><span class="pun">-></span><span class="pln">prepare</span><span class="pun">(</span><span class="pln"> </span><span class="str">"SELECT $type_column FROM $table WHERE meta_key = %s $value_clause"</span><span class="pun">,</span><span class="pln"> $meta_key </span><span class="pun">)</span><span class="pln"> </span><span class="pun">);</span></code></li><li class="L6"><code class="lang-php"><span class="pln"> </span><span class="pun">}</span></code></li></ol> |
如果输入
1 |
<ol class="linenums"><li class="L0"><code class="lang-php"><span class="pln"> $meta_value </span><span class="pun">=</span><span class="pln"> </span><span class="str">' %s '</span><span class="pun">;</span></code></li><li class="L1"><code class="lang-php"><span class="pln"> $meta_key </span><span class="pun">=</span><span class="pln"> </span><span class="pun">[</span><span class="str">'dump'</span><span class="pun">,</span><span class="pln"> </span><span class="str">' OR 1=1 /*'</span><span class="pun">];</span></code></li></ol> |
之后两次进入prepare()
,因为
1 |
<ol class="linenums"><li class="L0"><code class="lang-php"><span class="pln"> $query </span><span class="pun">=</span><span class="pln"> preg_replace</span><span class="pun">(</span><span class="pln"> </span><span class="str">'|(?<!%)%s|'</span><span class="pun">,</span><span class="pln"> </span><span class="str">"'%s'"</span><span class="pun">,</span><span class="pln"> $query </span><span class="pun">);</span></code></li></ol> |
使得%s
变为''%s''
最后结果
1 |
<ol class="linenums"><li class="L0"><code class="lang-sql"><span class="pln"> SELECT type FROM table WHERE meta_key </span><span class="pun">=</span><span class="pln"> </span><span class="str">'dump'</span><span class="pln"> AND meta_value </span><span class="pun">=</span><span class="pln"> </span><span class="str">''</span><span class="pln"> OR </span><span class="lit">1</span><span class="pun">=</span><span class="lit">1</span><span class="pln"> </span><span class="com">/*''</span></code></li></ol> |
WordPress也承认这是一个错误的修复。
在WordPress 4.8.3的补丁中,一是修改了meta.php
中两次使用prepare()
的问题,二是使用随机生成的占位符替换%
,在进入数据库前再替换回来。