Review of the classic sprintf format string vulnerability

Foreword

​ 抽时间帮助学校的队伍打了下上海大学生信息安全竞赛,题目质量还可以,最后ak了web题;有一道题利用sprintf经典格式化字符串漏洞进行逃逸单引号达到盲注的效果,最开始没想出来是这个点,后来本地测试了下发现是可以绕过的,发生在password处;

text

sprintf函数将格式化的字符输入到变量中;

1
sprintf(format,arg1,arg2,arg++)

arg1、arg2、++ 参数将被插入到主字符串中的百分号(%)符号处。该函数是逐步执行的。在第一个 % 符号处,插入 arg1,在第二个 % 符号处,插入 arg2,依此类推。

下面举两个例子看:

1
2
3
4
<?php 
$s1mple = 123;
$simple = sprintf("%f",$s1mple);
echo $simple;
1
2
3
4
<?php
$s1mple =123;
$simple = sprintf("保留两位小数:%1\$.2f"<br>"不带小数:%2\$u",$s1mple);
echo $simple;

这里因为我们百分号的数量大于我们需要传入参数的数量,所以这里需要使用占位符;占位符通常是由 数字+"\$"组成;

The complement of sprintf

鲜明的标志是补位符在单引号后面; ‘ 号(单引号)代表接下来要用补位类型

补位符有或者没有其实都问题不大;来看下面的两个对比的代码;

1
2
3
4
5
6
7
8
<?php
$a = "abcdef";
$b = "abcdef";

$c = "1234";
echo sprintf("%'9.2f",$c);
?>
//回显效果为1234.00
1
2
3
4
5
6
7
8
<?php
$a = "abcdef";
$b = "abcdef";

$c = "1234";
echo sprintf("%'x9.2f",$c);
?>
//回显效果为xx1234.00

通过这两个例子可以鲜明的对比,补位符只是起到了补充未够位数的作用,例子二就是拿x作为补位符;从而实现了x进行补位以达到例子二的输出效果;

Causes of sprintf format string vulnerability

sprintf对未知格式的判断,造成的漏洞;先来看下文档,里面记述了sprintf可以采用那些格式:

DZ7AX9.png

那么除了这些格式之外,php如果遇到了未知的格式,那么就会舍弃,这点在php底层源码中有体现;

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
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;
}

底层源码中对sprintf可以处理的字符格式做了鲜明的处理方法,但是对未知的字符格式则是直接进行了break;那么就会出现舍弃字符的现象,最后会导致引号逃逸(后续细说

看如下的例子:

1
2
3
4
<?php
$s1mple = 'aaa';
echo sprintf('%1$s',$s1mple);
?>

上述的例子可以正常地回显,但是如果我们传入一个未知的字符处理格式,那又会如何呢,在底层源码中我们可以发现,php会进行break从而舍弃;看下实例;

1
2
3
4
<?php
$s1mple = 'aaa';
echo sprintf('%1$as',$s1mple);
?>

上述例子的回显效果为s;说明了我们前面的非法的字符格式已经被break了,也就是我们的%1$a被舍弃,所以直接输出了s;写如下例子:

1
2
3
4
5
<?php
$s1mple = 'aaa';
echo sprintf('%1$a%s',$s1mple);
?>

我们看到如上的例子,会输出aaa,不难分析,因为我们前面的非法处理类型遭到的break舍弃,所以只剩下%s,所以自然正确的格式化输出了aaa;

这里如果将我们的非法格式化类型换成数字,那么也就是规范的格式化,因为%1$10s表示的是最后格式化第一个参数,接着保留十位字符,格式为字符串;这里不在过多的赘述;这种写法也就和之前讲的填充字符结合到一起了;因为位数不够的时候自然是需要进行填充,但是也可以没有填充字符,没有的时候默认是用空格进行填充;

Vulnerability description

经过我们上述的描述,我们会发现sprintf会进行字符的break;并且没有对字符有任何危险的处理,就简单的break;那么漏洞的点就是在这里触发;

SQL injection quotation mark escape caused by sprintf

来看如下的代码;

1
2
3
4
5
6
7
8
9
10
11
<?php
//addslashes()函数:在预定义前面加反斜杠,预定义符有单引号('),双引号("),反斜杠(\),NULL
$input = addslashes ("%1$' and 1=1#" );
//输出为 %1$\' and 1=1#
$b = sprintf ("AND b='%s'", $input );
//输出为 AND b='%1$\' and 1=1#'
$sql = sprintf ("SELECT * FROM t WHERE a='%s' $b ", 'admin' );
echo $sql ;
//输出为 SELECT * FROM t WHERE a='admin' AND b='' and 1=1#'
?>

通过这个例子我们可以看到,因为经过了addslashes函数的处理,我们的引号前会被默认的加入反斜杠进行转移,但是我们反斜杠在sprintf函数中可以错误引用从而被break掉;没有了反斜杠的转义,我们的引号自然会逃逸出来;从而进行sql的闭合达到sql injection;

Another interesting tirck

构造单引号,利用sprintf的格式化特性,可以进行单引号的引入;简单来说如果后面格式化的参数为数字的话,我们可以通过%c来引入进行转码,从而进入单引号,

1
2
3
4
5
6
<?php
$a = 39;
$b = sprintf('%c',$a);
echo $b;
//输出结果为 '

这种类型在有些专门设计的sql注入题中会有出现;简单的提一下,当作一个分享;

Application point of sprintf format string in actual combat

上海大学生信息安全竞赛的web2,是一个sql注入,盲注;漏洞点发生在class.php中

1
2
3
$sql="select * from user where username='%s' andpassword='$password'";
$sql=sprintf($sql,$username);

通过表单提交username和password,然后后续php代码处理,发现sql中格式化输入了username,这里并没有漏洞点可利用,但是password是没有经过格式化传入的,我们可以在password处利用非法的字符格式使其break掉,然后逃逸出单引号;因为传入的数据经过了addslashes;所以漏洞利用就更加明显了,直接嵌入 %1$’ || 1=1#;

这里经过aaddslashes函数处理之后,会在单引号前加入反斜杠,那么对于sprintf函数那就成了非法的字符格式,根据底层代码实现,会将其整体break;最后也就自然留下了单引号;然后闭合进行注入;

贴上一张在比赛时候的盲注测试图:

DZ79YT.png

回显登陆正确;原因看如下测试图:

DZ7CfU.png

可以让单引号逃逸出来,一个简单的分析,不是很难理解;

WordPress format string vulnerability analysis

这个漏洞比较的老版,版本需要小于4.7.5,经过测试在4.7.5中已经做了修补;现实的攻击意义不大,但是还是讲述一下;首先漏洞发生在删除图片的地方;

从upload.php处入手,发现post_id_del未经过任何处理就进入了程序的运作;

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
	case 'delete':
if ( !isset( $post_ids ) )
break;
foreach ( (array) $post_ids as $post_id_del ) {
if ( !current_user_can( 'delete_post', $post_id_del ) )
wp_die( __( 'Sorry, you are not allowed to delete this item.' ) );

if ( !wp_delete_attachment( $post_id_del ) )
wp_die( __( 'Error in deleting.' ) );
}
$location = add_query_arg( 'deleted', count( $post_ids ), $location );
break;
default:
/** This action is documented in wp-admin/edit-comments.php */
$location = apply_filters( 'handle_bulk_actions-' . get_current_screen()->id, $location, $doaction, $post_ids );
}

看到delete选块,简单理解代码,提交需要删除图片的id;然后进行权限检查,然后进入了一个未知的wp_delete_attachment函数,追踪函数,在post.php中发现了函数的定义;并且调用了个 delete_metadata 函数,

DZ7Fl4.png

继续跟进delete_metadata函数; 漏洞触发点主要在wp-includes/meta.php 的 delete_metadata函数里面 ;

DZ7j3D.png

发现调用了prepare函数,继续跟进函数发现有段操作;贴出来;

1
2
3
4
5
6
7
$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 );

这里分析一下,先将’%s’ 替换成了 %s 然后将 “%s”题换成了 %s 然后强制转化为浮点%F,把%s替换为’%s’,最后进行vsprintf格式化输入然后return;

我们拉到本地进行复现下

加入我们传入的meta_value 为 admin’ 进入这个语句;$wpdb->prepare( “ AND meta_value = %s”, $meta_value );

query经过prepare处理之后,最终变成 vsprintf(“ AND meta_value = ‘’%s’”, $meta_value);所以这里直接拼接后得到 and meta_value = ‘admin’;再return后;经过prepare函数处理后再得到;vsprintf( “SELECT $type_column FROM $table WHERE meta_key = ‘%s’ AND meta_value = ‘admin’”,’admin’)=> SELECT $type_column FROM $table WHERE meta_key = ‘admin’ AND meta_value = ‘admin’;语句无错误;

但是我们来看一下我们可控变量的走向; $post_id_del => $post_id => $meta_value => $args => $query

我们再回顾一下str_replace的替换,有一处是将’%s’替换为%s,然后再最后的时候又将%s替换成了’%s’,所以这两次替换没有什么实质性的干扰;

1
2
3
4
5
6
7
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 ) );
}

这里我们看处理后的一段流程,先进行了一次prepare处理,,然后再次经过prepare处理之后进行二次拼接,这里我们可控的变量再二次拼接的时候依然是可控的,那么我们如果想造成sql注入,那么这里就需要引入引号,让其再$value_clause处进行闭合,然后执行sql语句;

本地测试,经过第一次替换之后,$value_clause为 AND meta_value = ‘$meta_value’;如下图所示;

DZ7k6J.png

然后第二次替换的时候我们可以想办法引入引号,引号的引入就在我们的prepare函数的处理方式处,其处理将%s替换为’%s’;这里就是我们引号的引入点,看如下实例分析;

这里如果我们引入一个非法的字符格式进行处理;在文章的最开始就讲述了非法的字符格式会被break掉,那么我们这里就可以利用;如果我们传入 %1$%s AND SLEEP(5)#;这里引入的很巧妙,因为进行了替换的原因,我们在第二次的prepare处会变成 %1$'%s' AND SLEEP(5)#;这里由于发生了替换导致%1$'成为了非法的字符格式;从而导致其被break掉,那么只剩下%s;我们后边的引号自然的会逃逸出来,进行闭合,然后and sleep进行时间延迟;

其实这个漏洞言简意赅的总结一下,也就是利用vsprintf格式化字符串引入前后引号,然后利用非法的字符格式break掉前引号,然后后引号直接闭合继续后面拼接and sleep进行延时,sleep后的引号是再第一次的prepare处引入的,所以利用的很完美,造成时间延迟,可以进行注入;