轉載至:
https://www.anquanke.com/post/id/170850
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,則以空格代替。結果是1 moneky9(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中進行格式化的轉換,目前還需要進一步的研究。
Python
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接觸得比較少,所以這個代碼理解得還不是很透,需要進一步的實踐才能夠知道。
京亟:補充兩篇關於python的字符串格式化漏洞的文章
https://www.anquanke.com/post/id/170620
https://www.leavesongs.com/PENETRATION/python-string-format-vulnerability.html
總結
看似一些正常功能的函數在某些特殊情況下恰好能夠為埋下漏洞的隱患,而字符串格式化剛好就是一個這樣的例子,也從側面說明了安全需要猥瑣呀。