WordPress核心功能SQL注入漏洞分析
威脅響應中心研究員對Wordpress核心功能SQL注入漏洞(編號為CVE-2015-5623和CVE-2015-2213)進行了詳細的分析
0x00 漏洞概述
在twitter上看到Wordpress核心功能出現(xiàn)SQL注入漏洞,想學習下,就深入的跟了下代碼,結(jié)果發(fā)現(xiàn)老外留了好大的一個坑。雖然確實存在注入問題,但是卻沒有像他blog中所說的那樣可以通過訂閱者這樣的低權限來觸發(fā)SQL注入漏洞。
這個Wordpress漏洞系列的文章目前更新了兩個部分,一個是通過權限繞過實現(xiàn)訂閱者權限寫一篇文章到回收站,另一個就是通過寫入的這篇文章來實現(xiàn)SQL注入漏洞。這兩個漏洞的描述,TSRC的phithon寫的文章其實已經(jīng)很清楚了,我這里從我分析的角度來介紹這兩個漏洞的形成、利用以及phithon省略掉的原文部分內(nèi)容。
0x01 越權提交文章
_wpnonce的獲取
在講越權漏洞之前,我們需要介紹一下Wordpress后臺的_wpnonce參數(shù),這個參數(shù)主要是用來防止CSRF攻擊的token。后臺大多數(shù)敏感功能都會通過,當前用戶信息、功能名稱、操作對象id等內(nèi)容生成token,所以我們很難在沒有token的時候進行某些功能的使用。這個CSRF的防護機制間接地導致之后SQL注入很難再低權限情況下觸發(fā)(因為看不到token),后面講SQL注入漏洞時,我們會詳細的聊這個問題有多坑。
之所以要把_wpnonce的獲取放在前面講,是因為,我們需要一個讓我們可以使用編輯提交文章功能的token。這個功能的token我們可以通過訪問后臺post.php的post-quickdraft-save功能來獲取,嚴格的來說這個獲取方式也算是一個信息泄露漏洞,官方也在新版本中進行了修補。下面我我們來看下這個token泄露的原因。部分代碼如下:
case 'post-quickdraft-save':
// Check nonce and capabilities
$nonce = $_REQUEST['_wpnonce'];
$error_msg = false;
// For output of the quickdraft dashboard widget
require_once ABSPATH . 'wp-admin/includes/dashboard.php';
if ( ! wp_verify_nonce( $nonce, 'add-post' ) )
$error_msg = __( 'Unable to submit this form, please refresh and try again.' );
if ( ! current_user_can( 'edit_posts' ) )
$error_msg = __( 'Oops, you don’t have access to add new drafts.' );
if ( $error_msg )
return wp_dashboard_quick_press( $error_msg );
從上面代碼我們可以看出這個功能在發(fā)現(xiàn)出現(xiàn)錯誤時,會將錯誤信息通過wp_dashboard_quick_press這個函數(shù)打印到頁面上,而這個函數(shù)生成的頁面的代碼中有這樣一行:
wp_nonce_field( 'add-post' );
生成了add-post功能的_wpnonce,也就是說,即使我們做了一些被禁止的操作,返回頁面中也會出現(xiàn)這個_wpnonce。
越權提交與條件競爭
如phithon文章所說,由于GP使用邏輯混亂而導致的驗證繞過。我們來看post.php文件在開始檢驗文章是否存在的代碼:
if ( isset( $_GET['post'] ) )
$post_id = $post_ID = (int) $_GET['post'];
elseif ( isset( $_POST['post_ID'] ) )
$post_id = $post_ID = (int) $_POST['post_ID'];
else
$post_id = $post_ID = 0;
global $post_type, $post_type_object, $post;
if ( $post_id )
$post = get_post( $post_id );
if ( $post ) {
$post_type = $post->post_type;
$post_type_object = get_post_type_object( $post_type );
}
可以看到在先提取GET中的post參數(shù)作為文章id來提取文章信息,如果沒有GET的post參數(shù),再去用POST的post_ID參數(shù)來提取。而真正檢驗用戶是否對文章具有編輯權限,則是在edit_post中進行的,而這里的判斷參數(shù)是從POST中提取的:
function edit_post( $post_data = null ) {
global $wpdb;
if ( empty($post_data) )
$post_data = &$_POST;
// Clear out any data in internal vars.
unset( $post_data['filter'] );
$post_ID = (int) $post_data['post_ID'];
$post = get_post( $post_ID );
$post_data['post_type'] = $post->post_type;
$post_data['post_mime_type'] = $post->post_mime_type;
if ( ! empty( $post_data['post_status'] ) ) {
$post_data['post_status'] = sanitize_key( $post_data['post_status'] );
if ( 'inherit' == $post_data['post_status'] ) {
unset( $post_data['post_status'] );
}
}
$ptype = get_post_type_object($post_data['post_type']);
if ( !current_user_can( 'edit_post', $post_ID ) ) {
if ( 'page' == $post_data['post_type'] )
wp_die( __('You are not allowed to edit this page.' ));
else
wp_die( __('You are not allowed to edit this post.' ));
}
我們繼續(xù)看這段代碼最后的if判斷,判斷當前用戶是否有編輯這篇文章的權限。這個判斷最終的操作是在map_meta_cap這個函數(shù)中進行的:
case 'edit_post':
case 'edit_page':
$post = get_post( $args[0] );
if ( empty( $post ) )
break;
if ( 'revision' == $post->post_type ) {
$post = get_post( $post->post_parent );
}
可以看出如果文章是不存在的,那么就會break出switch,在函數(shù)結(jié)束時返回$caps變量,而$caps在函數(shù)開始的時候已經(jīng)被定義為一個空的數(shù)組了,也就是說,這里會返回一個空數(shù)組。下面我們來向前走,返回到調(diào)用map_meta_cap的has_cap函數(shù)中,看看后續(xù)的操作
public function has_cap( $cap ) {
if ( is_numeric( $cap ) ) {
_deprecated_argument( __FUNCTION__, '2.0', __('Usage of user levels by plugins and themes is deprecated. Use roles and capabilities instead.') );
$cap = $this->translate_level_to_cap( $cap );
}
$args = array_slice( func_get_args(), 1 );
$args = array_merge( array( $cap, $this->ID ), $args );
$caps = call_user_func_array( 'map_meta_cap', $args );
// Multisite super admin has all caps by definition, Unless specifically denied.
if ( is_multisite() && is_super_admin( $this->ID ) ) {
if ( in_array('do_not_allow', $caps) )
return false;
return true;
}
$capabilities = apply_filters( 'user_has_cap', $this->allcaps, $caps, $args, $this );
$capabilities['exist'] = true; // Everyone is allowed to exist
foreach ( (array) $caps as $cap ) {
if ( empty( $capabilities[ $cap ] ) )
return false;
}
return true;
}
看最后那個foreach,它檢測$caps中的各個元素在$capabilities中是否有不存在的,如果有那么就return false,但是$caps是個空數(shù)組,這樣很容易我們就獲得了一個true,權限校驗成功繞過。那么這樣我們可以得到的一個信息就是,我們可以通過這個缺陷去嘗試update一個并不存在的文章。
那么現(xiàn)在問題來了,直接update一個不存在的文章是沒有意義的,因為數(shù)據(jù)庫執(zhí)行SQL是肯定會報錯,我們要怎么才能成功創(chuàng)建一篇文章呢?
在校驗權限之后,數(shù)據(jù)庫執(zhí)行操作之前,post.php中有這樣一段代碼:
if ( isset( $post_data['tax_input'] ) ) {
foreach ( (array) $post_data['tax_input'] as $taxonomy => $terms ) {
// Hierarchical taxonomy data is already sent as term IDs, so no conversion is necessary.
if ( is_taxonomy_hierarchical( $taxonomy ) ) {
continue;
}
if ( ! is_array( $terms ) ) {
$comma = _x( ',', 'tag delimiter' );
if ( ',' !== $comma ) {
$terms = str_replace( $comma, ',', $terms );
}
$terms = explode( ',', trim( $terms, " \n\t\r\x0B," ) );
}
$clean_terms = array();
foreach ( $terms as $term ) {
// Empty terms are invalid input.
if ( empty( $term ) ) {
continue;
}
$_term = get_terms( $taxonomy, array(
'name' => $term,
'fields' => 'ids',
'hide_empty' => false,
) );
如果在POST的數(shù)據(jù)中存在名為tax_input的數(shù)組參數(shù),那么就對參數(shù)中的值使用逗號進行切割,然后對分割出來的每個內(nèi)容進行一次select查詢。那么這里我們可以想象一下,如果我們post_ID中填寫的是對當前最新文章ID+1的數(shù)值,并且在tax_input這個參數(shù)添加特別多的內(nèi)容,導致它不停地select查詢,我們是不是在這個停滯的時間當中插入一篇文章(這篇文章的ID剛好是最新文章ID+1),那么后面的update是不是就有意義了?
下面最后一個問題,我們?nèi)绾尾迦胍黄恼履?還記得post-quickdraft-save功能嗎?它的作用就是快速的保存一篇草稿!
這里可恥的盜一張phithon文章中的一幅圖,來讓各位加深條件競爭的流程:
小結(jié)
在調(diào)試過程中,感觸最深的的就是條件競爭,要很好的把握插入文章和update的時間差。如果查得太早則無法通過權限檢驗,如果太晚了又失去了意義。我獲得的經(jīng)驗是,**插入文章的進程盡量等待時間長一些,tax_input中的內(nèi)容盡可能的多,這樣能夠比較穩(wěn)定的在校驗之后,update之前插入文章。
當然這里面還有每個用戶只可以保存一個草稿的限制,漏洞發(fā)現(xiàn)者提供的建議是等一周的時間,草稿會自動刪除,而_wpnonce則會多保留一天。phithon的建議更好一些,使用兩個賬戶,一個賬戶插入文章,一個賬戶update。
一個越權漏洞由信息泄露、邏輯漏洞導致的權限繞過以及程序執(zhí)行時間可操控三個小漏洞組合而成,環(huán)環(huán)相扣,腦洞實在是大。
0x02 SQL注入漏洞
revision trick
漏洞發(fā)現(xiàn)者的文章最開始提到了一個revision的trick,用于承接越權寫文章之后如何繼續(xù)進行深入攻擊。但是由于這個老外隱藏了部分技巧,導致按照他文章所說的內(nèi)容無法復現(xiàn)訂閱者賬號觸發(fā)SQL注入漏洞,所以phithon在文章中沒有提到這個trick。我在這里本著還原作者思路的目的,向大家介紹一下關于這個trick的原理。
下面是原文關于這個trick部分的翻譯:
revisions字段用于記錄草稿或者更新的發(fā)布,Wordpress中用revisions記錄對于完成的提交和存儲都會在posts數(shù)據(jù)庫表中將post_type設置成revision,每個revision都會有一個’post_parent’字段,指明這個revision所基于的原版提交。
當嘗試編輯一個revision時,會通過post_parent來進行校驗,而不是revision本身。這樣,如果我們基于一個post創(chuàng)建一個revision,那么我們可以任意設置它的狀態(tài)為“trash”之外的任何狀態(tài),即使原始的post的狀態(tài)是“trash”
使用這個trick,我們能夠編輯這個“puppet revision”(傀儡文章?)和自由的向他添加評論,即使他的原版post已經(jīng)被丟棄到了垃圾箱當中。
結(jié)合我們上文的越權寫漏洞,可以看出作者提出使用這個trick的目的是為了,利用我們之前寫進垃圾箱中文章(post)的修訂版(revision)來進行編輯和評論進行操縱。因為類型為post的文章,即使是當前訂閱用戶提交的,也是沒有權限編輯的,而revision卻是可以編輯的。所以按照原文中所說,我們可以使用這個trick繼續(xù)后面的操作。
漏洞原因
這個漏洞其實是一個二次注入的漏洞,它的本質(zhì)原因是在從垃圾箱還原文章時,也要還原文章下面的評論,而在還原評論的代碼中存在直接拼接用戶可控內(nèi)容,從而導致SQL注入漏洞。下面為問題代碼:
function wp_untrash_post_comments( $post = null ) {
global $wpdb;
$post = get_post($post);
if ( empty($post) )
return;
$post_id = $post->ID;
$statuses = get_post_meta($post_id, '_wp_trash_meta_comments_status', true);
if ( empty($statuses) )
return true;
do_action( 'untrash_post_comments', $post_id );
// Restore each comment to its original status.
$group_by_status = array();
foreach ( $statuses as $comment_id => $comment_status )
$group_by_status[$comment_status][] = $comment_id;
foreach ( $group_by_status as $status => $comments ) {
// Sanity check. This shouldn't happen.
if ( 'post-trashed' == $status )
$status = '0';
$comments_in = implode( "', '", $comments );
$wpdb->query( "UPDATE $wpdb->comments SET comment_approved = '$status' WHERE comment_ID IN ('" . $comments_in . "')" );
}
clean_comment_cache( array_keys($statuses) );
delete_post_meta($post_id, '_wp_trash_meta_comments_status');
do_action( 'untrashed_post_comments', $post_id );
}
從代碼中我們可以看到$status和$comments變量的內(nèi)容會拼接到SQL中,而這兩個變量的內(nèi)容是用戶可以操控的。因為用戶傳入的數(shù)據(jù)會先被過濾處理后存儲到數(shù)據(jù)庫中,然后直接從數(shù)據(jù)庫提取數(shù)據(jù)進行拼接,所以如果我們的攻擊語句會在數(shù)據(jù)庫提取時恢復本來面貌——標準的二次注入。
漏洞利用&對作者深深的怨念
看懂了revision和漏洞原因,利用應該很簡單:
對寫入文章的revision進行評論
編輯評論狀態(tài)為注入攻擊語句
將revision丟入垃圾箱
還原最開始的文章(revision的原版)
看似沒有什么問題,不知道各位還記不記得我們上文說的_wpnonce。對!就是這個坑!作者通篇沒有提到這個漏洞要怎么獲取_wpnonce!這最直接的導致我們第1、2、4步?jīng)]有辦法玩了o(╯□╰)o。
我猜測作者沒有提及這個問題,要么是他自己也沒找到,這篇文章其實只是個標題黨,要么是他藏了一手。從作者前后漏洞關聯(lián)的那么緊密來看,我比較傾向于后一種原因,所以這里要幽怨的瞪他一眼~~
小結(jié)
作者利用讀取數(shù)據(jù)庫中存儲內(nèi)容沒有進行過濾的盲點進行二次注入的思路比較直接,但是利用revision編輯垃圾箱中文章這個點真心贊。
在分析過程中發(fā)現(xiàn)revision的編輯并非向作者所說的可以改成trash之外的任意類型,文章狀態(tài)大概有publish、future、private、inherit、auto-draft、attachment、draft、pedding、trash這幾種,而我們所能修改的文章類型只能是inherit、pedding和draft這三種。否則,如果我可以修改文章狀態(tài)為private,從前臺完成利用第1,2步操作.
斷斷續(xù)續(xù)的跟了這個漏洞一周的時間了,最大的感受就是作者真心是個坑o(╯□╰)o
0x03 總結(jié)
跟這個漏洞體會最深的就是Wordpress的token機制在防護csrf的同時,也協(xié)助防御了其他類型的攻擊,一個很好的機制。
GP操作的邏輯是Web代碼的一個問題,尤其是在做一些權限校驗時候,很容易出現(xiàn)Wordpress這樣的GP交叉導致的邏輯混亂問題。
二次注入應該算是一個老生常談的問題了,過于信任已記錄的數(shù)據(jù),最終會導致忘記這條記錄其實是用戶寫入的。
申請創(chuàng)業(yè)報道,分享創(chuàng)業(yè)好點子。點擊此處,共同探討創(chuàng)業(yè)新機遇!