PHP字串格式化特點和漏洞利用點
PHP中的格式化字串函式
在PHP中存在多個字串格式化函式,分別是 printf()
、 sprintf()
、 vsprintf()
。他們的功能都大同小異。
- printf,
int printf ( string $format [, mixed $args [, mixed $... ]] )
,直接將格式化的結果輸出,返回值是int。 - sprintf,
string sprintf ( string $format [, mixed $args [, mixed $... ]] )
,返回格式化字串的結果 - vsprintf,
string vsprintf ( string $format , array $args )
,與sprintf()
相似,不同之處在於引數是以陣列的方式傳入。
三者的功能類似,以下僅以 sprintf()
來說明常規的格式化字串的方法。
單個引數格式化的方法
var_dump(sprintf('1%s9','monkey'));# 格式化字串。結果是1monkey9 var_dump(sprintf('1%d9','456'));# 格式化數字。結果是14569 var_dump(sprintf("1%10s9",'moneky'));# 設定格式化字串的長度為10,如果長度不足10,則以空格代替。結果是1moneky9(length=12) var_dump(sprintf("1%10s9",'many monkeys')); # 設定格式化字串的長度為10,如果長度超過10,則保持不變。結果是1many monkeys9(length=14) var_dump(sprintf("1%'^10s9",'monkey'));# 設定格式化字串的長度為10,如果長度不足10,則以^代替。結果是1^^^^monkey9(length=12) var_dump(sprintf("1%'^10s9",'monkey'));# 設定格式化字串的長度為10,如果長度超過10,則保持不變。結果是1many monkeys9(length=14)
多個引數格式化的方法
$num = 5; $location = 'tree'; echo sprintf('There are %d monkeys in the %s', $num, $location);# 位置對應, echo sprintf('The %s contains %d monkeys', $location, $num);# 位置對應 echo sprintf('The %2$s contains %1$d monkeys', $num, $location);# 通過%2、%1來申明需要格式化的是第多少個引數,比如%2$s表示的是使用第二個格式化引數即$location進行格式化,同時該引數的型別是字串型別(s表明了型別)
在格式化中申明的格式化引數型別有幾個就說明是存在幾個格式化引數,在上面的例子都是兩個引數。如果是下方這種:
echo sprintf('The %s contains %d monkeys', 'tree');# 返回結果為False
則會出現 Too few arguments
,因為存在兩個格式化引數 %s
和 %d
但僅僅只是傳入了一個變數 tree
導致格式化出錯返回結果為False,無法進行格式化。
格式化字串的特性
除了上面的一般用法之外,格式化中的一些怪異的用法常常被人忽略,則這些恰好是漏洞的來源。
字串padding
常規的padding預設採用的是空格方式進行填充,如果需要使用其他的字元進行填充,則需要以 %'[需要填充的字元]10s
格式來表示,如 %'#10s
表示以 #
填充, %'$10s
表示以 $
填充
var_dump(sprintf("1%10s9",'monkey'));# 使用空格進行填充 var_dump(sprintf("1%'#10s9",'monkey'));# 使用#填充,結果是 1####monkey9 var_dump(sprintf("1%'$10s9",'monkey'));# 使用$填充,結果是 1$$$$monkey9
從上面的例子看到, 在某些情況下單引號在格式化時會被吞掉,而這就有可能會埋下漏洞的隱患。
字串按位置格式化
按位置格式化字串的常規用法
$num = 5; $location = 'tree'; var_dump(sprintf('The %2$s contains %1$d monkeys', $num, $location));
這種制定引數位置的格式化方法會使用到 %2$s
這種格式化的方式表示。其中 %2
表示格式化第二個引數, $s
表示需要格式化的引數型別是字串。如下:
var_dump(sprintf('%1$s-%s', 'monkey'));# 結果是monkey-monkey
因為 %1$s
表示格式化第一個字串,而後面的 %s
預設情況下同樣格式化的是第一個字串,所以最終的結果是 monkey-monkey
。如果是:
var_dump(sprintf('%2$s-%s', 'monkey1','monkey2'));# 結果是monkey2-monkey1
因為 %2$s
格式化第二個字串, %s
格式化第一個字串。
下面看一些比較奇怪的寫法。首先我們需要知道在 sprintf用法 中已經說明了可以格式化的型別
如果遇到無法識別的格式化型別呢?如:
var_dump(sprintf('%1$as', 'monkey'));# 結果是s
由於在格式化型別中不存在 a
型別,導致格式化失敗。此時 %1$a
在格式化字串時無用就直接捨棄,最後得到的就是 s
。但是如果我們寫成:
var_dump(sprintf('%1$a%s', 'monkey'));# 結果是monkey
因為 %1$a%s
中 a
為無法識別的型別,則直接捨棄。剩下的 %s
可以繼續進行格式化得到 monkey
那麼結論就是 %1$[格式化型別]
,如果所宣告的格式化型別不存在,則 %1$[格式化型別]
會被全部捨棄,留下剩下的字元。
如果在 $
接上數字呢?如 %1$10s
呢?
var_dump(sprintf('%1$10s', 'monkey'));# 結果是'monkey' (length=10)
此時表示的是格式化字串的長度,預設使用的是空格進行填充。如果需要使用其他的字串填充呢?此時格式是 %1$'[需要填充的字元]10s
。
var_dump(sprintf("%1$'#10s", 'monkey'));# 結果是 '####monkey' (length=10)
除此之外,還存在一些其他的奇怪的用法,如下:
var_dump(sprintf("%1$'%s", 'monkey'));# 得到的結果就是 monkey `
按照之前的說法,由於 '
是無法識別的型別,所以 %1$'
會被捨棄,剩餘的 %s
進行格式化得到的就是 monkey
。可以發現在這種情況下 '
已經消失了。假設程式經過過濾得到的字串是 %1$'%s'
,那麼就會導致中間的 '
被吞掉,如下:
var_dump(sprintf("%1$'%s'", 'monkey'));# 得到的結果是 monkey'
吞掉引號
對上面進行一個簡單的總結,除了一些不常見的字串的格式化用法之外,還存在一些吞掉引號的用法。都是處在字串padding的情況下。
var_dump(sprintf("1%'#10s9",'monkey'));# 使用#填充,結果是 1####monkey9 var_dump(sprintf("%1$'#10s", 'monkey'));# 結果是 '####monkey' (length=10)
這兩種 '
被吞掉的情況都有可能會引起漏洞。
漏洞示例
通過一段存在漏洞的程式碼來說明這種情況
$value1 = $_GET['value1']; $value2 = $_GET['value2']; $a = prepare("AND meta_value=%s",$value1); $b = prepare("SELECT * FROM table WHERE key=%s $a",$value2); function prepare($query,$args) { $query = str_replace("'%s'",'%s',$query); $query = str_replace('"%s"','$s',$query); $query = preg_replace('|(?<!%)%f|','%F',$query); $query = preg_replace('|(?<!%)%s|', "'%s'", $query); return @vsprintf($query,$args); }
$value1
和 $value2
是使用者可控,函式 prepare()
會去掉格式化字串 %s
的單引號和雙引號,同時在最後加上單引號。雖然最後加上了一個 '
,但是我們還是有辦法能夠逃脫這個單引號。利用方式就是通過之前申明字串填充padding的方式吞掉單引號。
利用%1$’%s
之前已經說過 sprintf("%1$'%s", 'monkey')
就可以吞掉其中的 '
。那麼在本例中,我們可以設定:
$value1 = '1 %1$%s (here sqli payload) --'; $value2 = '_dump';
此時,經過 $a = prepare("AND meta_value=%s",$value1);
,得到 $a
是 AND meta_value='1 %1$%s (here sqli payload) --'
。之後執行 $b = prepare("SELECT * FROM table WHERE key=%s $a",$value2);
,其中 $value2
是 _dump
。下面仔細分析:
經過 $query = preg_replace('|(?<!%)%s|', "'%s'", $query)
會將所有的 %s
全部變為 '%s'
,所以此時得到的 $query
是 SELECT * FROM table WHERE key='%s' AND meta_value='1 %1$'%s' (here sqli payload) --'
。
此時其中剛好存在有 1 %1$'%s
這種形式的格式化字串,導致其中的 %1$'
會被去除,剩下 1 %s'
,此時就類似於 SELECT * FROM table WHERE key='%s' AND meta_value='1 %s' (here sqli payload) --'
,格式化 vsprintf("SELECT * FROM table WHERE key='%s' AND meta_value='1 %s' (here sqli payload) --'",_dump)
剛好閉合了前面的單引號形成SQL注入。得到的結果如下:
方式二
上面利用的是 %1$'%s
,即在位置宣告時出錯導致吞掉單引號的方式,本方式是通過自身引入 '
與加入的單引號重合的方式。如:
$query = '1 %s 2'; $query = preg_replace('|(?<!%)%s|', "'%s'", $query);# 得到 1 '%s' 2' $query = preg_replace('|(?<!%)%s|', "'%s'", $query);# 得到 1 ''%s'' 2
可以發現經過兩次相同的過濾,最終導致 %s
逃逸出來。而在本題中的 $value1
同樣是經過了兩個的過濾。
所以,我們如果設定
$value1 = ' %s ';# 注意%s 前後的空格 $value2 = array('_dump', '(here sqli payload) --');
經過 $a = prepare("AND meta_value=%s",$value1);
得到 $a
為 AND meta_value=' %s '
。其中 $value
是 array('_dump', '(here sqli payload) --')
,分析程式碼 $b = prepare("SELECT * FROM table WHERE key=%s $a",$value2);
。
分析執行 $query = preg_replace('|(?<!%)%s|', "'%s'", $query);
之前和之後的程式碼:
執行之前,$query為“
執行之後,$query為 SELECT * FROM table WHERE key='%s' AND meta_value=' '%s' '
可以發現所有的 %s
全部被左右全被加上了單引號,剛好與之前的單引號進行匹配,導致 AND meta_value=' '%s' '
中的 %s
逃逸出來。最後的幾個就是 SELECT * FROM table WHERE key='_dump' AND meta_value=' '(here sqli payload) --' '
。
其他
雖然本篇文章主要討論的是PHP中的字串漏洞,但是對於其他語言如(Java/Python)也在這裡進行一個簡單的討論。(以下的例子借用的是xiaoxiong文章 wordpress 格式化字串注入 中的例子)
Java格式化
StringBuilder sb = new StringBuilder(); Formatter formatter = new Formatter(sb, Locale.US); formatter.format("%s %s %1$s", "a", "monkey"); System.out.println(formatter);
最後輸出的結果是 a monkey a
,因為前面兩個 %s
是按照順序取,得到的是 a
和 monkey
,而後面的 %1$s
按照位置取,得到的是 a
,所以最後的結果是 a monkey a
。
如果寫為:
StringBuilder sb = new StringBuilder(); Formatter formatter = new Formatter(sb, Locale.US); formatter.format("%s %s '%2$c %1$s", "a", 39, "c", "d"); System.out.println(formatter);
最後得到的結果是 a 39 '' a
,前面兩個 %s
按照順序去得到 a
和 39
,而 %1$s
取第一個引數,得到 a
。 %2$c
取第二個引數,並且將其值作為數字得到其對應的ASCII字元,因為39對應的ASCII字元是 '
,所以 '%2$c
得到的就是 ''
。
那麼,我們能否借鑑PHP中的思路,吞掉 '
呢?
StringBuilder sb = new StringBuilder(); Formatter formatter = new Formatter(sb, Locale.US); formatter.format("%2$'s", "a", "monkey"); System.out.println(formatter);
程式會出現 java.util.UnknownFormatConversionException
,無法進行型別轉換的錯誤,所以利用Java中進行格式化的轉換,目前還需要進一步的研究。
def view(request, *args, **kwargs): template = 'Hello {user}, This is your email: ' + request.GET.get('email') return HttpResponse(template.format(user=request.user)) poc: http://localhost:8000/?email={user.groups.model._meta.app_config.module.admin.settings.SECRET_KEY} http://localhost:8000/?email={user.user_permissions.model._meta.app_config.module.admin.settings.SECRET_KEY}
這個程式碼是基於Django的環境下的存在漏洞的程式碼。通過第一次格式化改變了語句結構,第二次格式化進行賦值。由於平時對Django接觸得比較少,所以這個程式碼理解得還不是很透,需要進一步的實踐才能夠知道。
總結
看似一些正常功能的函式在某些特殊情況下恰好能夠為埋下漏洞的隱患,而字串格式化剛好就是一個這樣的例子,也從側面說明了安全需要猥瑣呀。