WordPress 4.8.3中修复了一个重要的SQL注入漏洞。漏洞是今年9月20日由Hacker-One报告的。本文主要讲了漏洞的技术细节和解决方法。

升级到最新版本

网站管理员应该升级WordPress到4.8.3版本并更新重写 $wpdb的所有插件,就可以预防此类问题。为客户机升级wp-db.php,可能需要修改一些防火墙规则,比如拦截 %s 和其他sprintf() 值。


插件开发者应该?

一般来说,检查所有的查询段的用户输入,不要将用户输入传递到查询端,如:

$where = $wpdb->prepare(" WHERE foo = %s", $_GET['data']);$query = $wpdb->prepare("SELECT * FROM something $where LIMIT %d, %d", 1, 2);

$where = "WHERE foo = '" . esc_sql($_GET['data']) . "'";$query = $wpdb->prepare("SELECT * FROM something $where LIMIT %d, %d", 1, 2);

以上两种方法从概念是讲都是不安全的,

安全的查询方法为:

$where = "WHERE foo = %s";$args = [$_GET['data']];$args[] = 1;$args[] = 2;$query = $wpdb->prepare("SELECT * FROM something $where LIMIT %d, %d", $args);


漏洞

WPDB::prepare源码(4.8.2之前版本):

public function prepare( $query, $args ) {
    if ( is_null( $query ) )
        return;
    // This is not meant to be foolproof -- but it will catch obviously incorrect usage.
    if ( strpos( $query, '%' ) === false ) {
        _doing_it_wrong( 'wpdb::prepare', sprintf( __( 'The query argument of %s must have a placeholder.' ), 'wpdb::prepare()' ), '3.9.0' );
    }
    $args = func_get_args();
    array_shift( $args );
    // If args were passed as an array (as in vsprintf), move them up
    if ( isset( $args[0] ) && is_array($args[0]) )
        $args = $args[0];
    $query = str_replace( "'%s'", '%s', $query ); // in case someone mistakenly already singlequoted it
    $query = str_replace( '"%s"', '%s', $query ); // doublequote unquoting
    $query = preg_replace( '|(?<!%)%f|' , '%F', $query ); // Force floats to be locale unaware
    $query = preg_replace( '|(?<!%)%s|', "'%s'", $query ); // quote the strings, avoiding escaped strings like %%s
    array_walk( $args, array( $this, 'escape_by_ref' ) );
    return @vsprintf( $query, $args );}
  1. 用vsprintf(与sprintf基本等价)的值来替换占位符;

  2. 用str_replace来适当地引用占位符;

  3. 如果传递了一个参数,而这个参数是数组的话,用数组的值来替换参数。

这意味着调用$wpdb->prepare($sql, [1, 2]) 与调用$wpdb->prepare($sql, 1, 2)是等价的。

最初报告的漏洞依赖与下面的服务端代码:

$items = implode(", ", array_map([$wpdb, '_real_escape'], $_GET['items']));$sql = "SELECT * FROM foo WHERE bar IN ($items) AND baz = %s";
$query = $wpdb->prepare($sql, $_GET['baz']);

漏洞利用vsprintf的特征来允许绝对引用参数,例子如下:

vsprintf('%s, %d, %s', ["a", 1, "b"]); // "a, 1, b"vsprintf('%s, %d, %1$s', ["a", 2, "b"]); // "a, 2, a"

注意%n$s不会读下一个参数,但是会读第n个位置的参数。可以根据这个特性在原始查询中进行注入。假设传递下面的信息到请求中:

$_GET['items'] = ['%1$s'];$_GET['baz'] = "test";
查询会变成 SELECT * FROM foo WHERE bar IN ('test') AND baz = 'test';我们成功地改变了查询的本意。
There’s one other key piece of information that the original report included to change this into a full-blown SQL Injection. sprintf also accepts another type of parameter: %c which acts like chr() and converts a decimal digit into a character. So now, the attacker can do this:

最初的漏洞报告中还有一个关键的信息是可以把这个变成成熟的SQL注入。Sprintf也会接受其他类型的参数,%c与chr()含义相同,可以把小叔变成字符,所以攻击者可以:

$_GET['items'] = ['%1$c) OR 1 = 1 /*'];$_GET['baz'] = 39;

ASCII表中39代表’(单引号),所以查询就变成了这样:

SELECT * FROM foo WHERE bar IN ('') OR 1 = 1 /*' AND baz = 'test';

注入就完成了。

这个过程看似很复杂,需要提前准备好输入的参数等,实际上该漏洞也存在于核心文件/wp-includes/meta.php 中:

if ( $delete_all ) {
  $value_clause = '';
  if ( '' !== $meta_value && null !== $meta_value && false !== $meta_value ) {
    $value_clause = $wpdb->prepare( " AND meta_value = %s", $meta_value );
  }
  $object_ids = $wpdb->get_col( $wpdb->prepare( "SELECT $type_column FROM $table WHERE meta_key = %s $value_clause", $meta_key ) );}


最早的补丁

WordPress4.8.2发布时,就包含上述问题的一个补丁。补丁整个包含在WPDB::prepare()中,补丁只加了1行代码:

$query = preg_replace( '/%(?:%|$|([^dsF]))/', '%%\\1', $query );

这1行代码做了2件事情。1是移除了除%d,%s,%F之外的sprintf令牌,因为漏洞是依赖%c的,因此使漏洞无效。2是移除了位置替换的能力,即%1$s这样的参数就无效了。

这引起了开发人员的不满,因为WordPress在官方文档中说只能使用%d,%s,%F。即使官方文档是这么写的,上百万的第三方查询代码都使用了前面的语法规则。

WordPress的回应是“won’t fix, sorry”,并以安全为由拒绝提供更多细节。


最初补丁的第一个问题

漏洞是传递用户输入到prepare的服务端。最初漏洞的POC是这样的,安全查询代码如下:

$db->prepare("SELECT * FROM foo WHERE name= '%4s' AND user_id = %d", $_GET['name'], get_current_user_id());

4.8.2中的变化是%4s会被重写成%%4s,也就是说%d会反弹到$_GET['name'],给了攻击者用户id的机会。这可以被用来进行权限提升攻击。

WordPress的回应是:“thank you, we don’t support that”。


全面攻击

然后作者设计了一个不同的POC,利用另一个重要的事实来证明该漏洞不是%1$s,而是传递用户输入到prepare查询端。Meta.php文件代码如下:

if ( $delete_all ) {
  $value_clause = '';
  if ( '' !== $meta_value && null !== $meta_value && false !== $meta_value ) {
    $value_clause = $wpdb->prepare( " AND meta_value = %s", $meta_value );
  }
  $object_ids = $wpdb->get_col( $wpdb->prepare( "SELECT $type_column FROM $table WHERE meta_key = %s $value_clause", $meta_key ) 
 );}

输入:

$meta_value = ' %s ';$meta_key = ['dump', ' OR 1=1 /*'];

产生了下面的查询:

SELECT type FROM table WHERE meta_key = 'dump' AND meta_value = '' OR 1=1 /*'

成功注入了核心文件,$meta_value 和 $meta_key都来自于用户的输入。会产生下面的赋值子句:

AND meta_value = ' %s '

未引用的%s通过prepare被引用的%代替,第二次调用->prepare()把clause变成AND meta_value = ' '%s' ' ,就可以注入了。

作者强调该漏洞不能在WPDB::prepare() 修复,但是是meta.php中的问题。可以通过预防double prepare calls缓解该漏洞。但是不能修复原始漏洞。


简单补丁

简单的补丁不是传递用户输入的$query参数到meta.php中的WPDB::prepare()。传递用户输入到$query是错误的。


缓解补丁

下一步是在预查询中引用占位符,然后在执行查询前恢复占位符,这个补丁已经有了。基本上,补丁会修改WPDB::prepare()把随机字数穿用%占位符代替,比如:

$query = str_replace('%', "{$this->placeholder_escape}", $query );

然后,在WPDB::_do_query()去除占位符来恢复最初的用户的用户输入。

我仍然认为传递用户输入到prepare的查询端是存在潜在危险的而且是不安全的。即使你解决了已知的安全漏洞,double-preparing字符串是及其危险的,因为prepare的结果会传递到另一个。


正确的补丁

正确的补丁应该是抛弃整个prepare机制。像正常的查询那样,返回一个statement或query的对象,或者直接执行查询。这种方式可以预防double prepare字符串的情况。值得一提的是这将会是WP的主要变化。其他平台已经有成功的先例了,比如PHPBB经历了同样的事情,从大规模的SQL注入漏洞到几乎没有SQL注入漏洞。也不需要很快解决,可以与现有的API并行处理,慢慢地去取代老的API。目前的系统在设计之处就是不安全的,但这也不意味着会经常被黑,但是你要尽量去让它不被黑。最好使用默认安全的设计,并让不安全成为特例。其中最佳的实践方法是使用PDO/MySQLi和real prepared statements。这些变化并不能防止被误用,但是会让误用变得更难。


https://blog.ircmaxell.com/2017/10/disclosure-wordpress-wpdb-sql-injection-technical.html

源链接

Hacking more

...