
WordPress 核心功能 SQL 注入漏洞分析
威脅響應中心研究員對 WordPress 核心功能 SQL 注入漏洞 (編號為 CVE-2015-5623 和 CVE-2015-2213) 進行了詳細的分析
0x00 漏洞概述
在 twitter 上看到 WordPress 核心功能出現 SQL 注入漏洞,想學習下,就深入的跟了下代碼,結果發現老外留了好大的一個坑。雖然確實存在注入問題,但是卻沒有像他 blog 中所説的那樣可以通過訂閲者這樣的低權限來觸發 SQL 注入漏洞。
這個 WordPress 漏洞系列的文章目前更新了兩個部分,一個是通過權限繞過實現訂閲者權限寫一篇文章到回收站,另一個就是通過寫入的這篇文章來實現 SQL 注入漏洞。這兩個漏洞的描述,TSRC 的 phithon 寫的文章其實已經很清楚了,我這裏從我分析的角度來介紹這兩個漏洞的形成、利用以及 phithon 省略掉的原文部分內容。
0x01 越權提交文章
_wpnonce 的獲取
在講越權漏洞之前,我們需要介紹一下 WordPress 後台的_wpnonce 參數,這個參數主要是用來防止 CSRF 攻擊的 token 。後台大多數敏感功能都會通過,當前用户信息、功能名稱、操作對象 id 等內容生成 token,所以我們很難在沒有 token 的時候進行某些功能的使用。這個 CSRF 的防護機制間接地導致之後 SQL 注入很難再低權限情況下觸發 (因為看不到 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 );
從上面代碼我們可以看出這個功能在發現出現錯誤時,會將錯誤信息通過 wp_dashboard_quick_press 這個函數打印到頁面上,而這個函數生成的頁面的代碼中有這樣一行:
- PHP wp_nonce_field( 'add-post' ); 1 wp_nonce_field('add-post');
生成了 add-post 功能的_wpnonce,也就是説,即使我們做了一些被禁止的操作,返回頁面中也會出現這個_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 參數作為文章 id 來提取文章信息,如果沒有 GET 的 post 參數,再去用 POST 的 post_ID 參數來提取。而真正檢驗用户是否對文章具有編輯權限,則是在 edit_post 中進行的,而這裏的判斷參數是從 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.' ));
}
我們繼續看這段代碼最後的 if 判斷,判斷當前用户是否有編輯這篇文章的權限。這個判斷最終的操作是在 map_meta_cap 這個函數中進行的:
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,在函數結束時返回 $caps 變量,而 $caps 在函數開始的時候已經被定義為一個空的數組了,也就是説,這裏會返回一個空數組。下面我們來向前走,返回到調用 map_meta_cap 的 has_cap 函數中,看看後續的操作
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 是個空數組,這樣很容易我們就獲得了一個 true,權限校驗成功繞過。那麼這樣我們可以得到的一個信息就是,我們可以通過這個缺陷去嘗試 update 一個並不存在的文章。
那麼現在問題來了,直接 update 一個不存在的文章是沒有意義的,因為數據庫執行 SQL 是肯定會報錯,我們要怎麼才能成功創建一篇文章呢?
在校驗權限之後,數據庫執行操作之前,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, "
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 的數據中存在名為 tax_input 的數組參數,那麼就對參數中的值使用逗號進行切割,然後對分割出來的每個內容進行一次 select 查詢。那麼這裏我們可以想象一下,如果我們 post_ID 中填寫的是對當前最新文章 ID+1 的數值,並且在 tax_input 這個參數添加特別多的內容,導致它不停地 select 查詢,我們是不是在這個停滯的時間當中插入一篇文章 (這篇文章的 ID 剛好是最新文章 ID+1),那麼後面的 update 是不是就有意義了?