CVE-2018-7600影响范围包括了Drupal 6.x,7.x,8.x版本,前几天8.x版本的PoC出来之后大家都赶紧分析了一波,然后热度似乎慢慢退去了。两天前Drupalgeddon2项目更新了7.x版本的exp,实际环境也出现了利用,下面就简单来看一下
CVE-2018-7600影响范围包括了Drupal 6.x,7.x,8.x版本,前几天8.x版本的PoC出来之后大家都赶紧分析了一波,然后热度似乎慢慢退去了。两天前Drupalgeddon2项目更新了7.x版本的exp,实际环境也出现了利用,下面就简单来看一下
看到项目上这样写
Drupal < 7.58 ~ user/password URL, attacking triggering_element_name form & #post_render parameter, using PHP’s passthru function
提示了问题出在user/password
路径下,通过#post_render
传递恶意参数,问题出现在triggering_element_name
表单处理下
我们从三个问题入手,为什么PoC发了两个包,第二次请求为什么要带上一个form_build_id
,以及为什么选择user/password
这个入口
先分析第一个post,照例还是先看一下Drupal 7的表单处理流程,跟8版本不太一样,但是入口还是相似的。
根据文档描述,当我们提交一个表单(例如找回密码)时,系统会通过form_builder()
方法创建一个form
一系列预处理后,会由drupal_build_form
方法创建一个表单,在第386行调用
()drupal_process_form()
方法,
跟进drupal_process_form()
方法,这时候默认的$form_state['submitted']
为false
不满足if条件,$form_state['submitted']
被设置为true
于是进入这个分支,最终被drupal_redirect_form
重定向
我们的目的是要让系统缓存一个form_build_id
,以便后面拿出来用。要想form被缓存,就得想办法让if ($form_state['submitted'] && !form_get_errors() && !$form_state['rebuild'])
不成立,也就是说要使$form_state['submitted']
为false
从而进入下面的drupal_rebuild_form
那么如何让$form_state['submitted']
为false呢?
在includes/form.inc
第886行
$form = form_builder($form_id, $form, $form_state);
跟进form_builder
方法,第1987行
1 2 3 4 |
if (!empty($form_state['triggering_element']['#executes_submit_callback'])) { $form_state['submitted'] = TRUE; } |
当$form_state['triggering_element']['#executes_submit_callback']
存在值的时候就为true,那么我们就想办法让这个值为空
往上看第1972行
1 2 3 4 |
if (!$form_state['programmed'] && !isset($form_state['triggering_element']) && !empty($form_state['buttons'])) { $form_state['triggering_element'] = $form_state['buttons'][0]; } |
如果没有设置$form_state['triggering_element']
,那么$form_state['triggering_element']
就设置为第一个button的值,所以正常传递表单的时候$form_state['triggering_element']['#executes_submit_callback']
就总会有值
现在问题来了,如何构造一个form能够确保$form_state['triggering_element']['#executes_submit_callback']
为空或者说不存在这个数组呢?
我们注意到第1864行
1 2 3 4 |
if (!empty($element['#input'])) { _form_builder_handle_input_element($form_id, $element, $form_state); } |
_form_builder_handle_input_element()
方法对表单先进行了处理,跟进去看一下
第2144行
1 2 3 4 5 6 7 8 9 10 |
// Determine which element (if any) triggered the submission of the form and // keep track of all the clickable buttons in the form for // form_state_values_clean(). Enforce the same input processing restrictions // as above. if ($process_input) { // Detect if the element triggered the submission via Ajax. if (_form_element_triggered_scripted_submission($element, $form_state)) { $form_state['triggering_element'] = $element; } |
这里$form_state['triggering_element']
被设置为$element
,前提是满足_form_element_triggered_scripted_submission()
方法,继续跟入
第2180行
1 2 3 4 5 6 7 8 9 |
function _form_element_triggered_scripted_submission($element, &$form_state) { if (!empty($form_state['input']['_triggering_element_name']) && $element['#name'] == $form_state['input']['_triggering_element_name']) { if (empty($form_state['input']['_triggering_element_value']) || $form_state['input']['_triggering_element_value'] == $element['#value']) { return TRUE; } } return FALSE; } |
这个方法的意思是说如果_triggering_element_value
和$element
的键值都相等的话,返回true
$form_state['triggering_element']
赋值为$element
,其中不含['#executes_submit_callback']
,一开始的条件就成立了
根据PoC,我们传入_triggering_element_name=name
看到进入这个分支,进入form_set_cache()
方法
数据库中插入缓存form_build_id
成功写入缓存
接下去来看一下这个缓存有什么用
分析PoC的第二个包,请求参数是这样q=file/ajax/name/%23value/form_build_id
form_build_id
即我们上一个写入数据库的缓存表单
首先请求会进入includes/menu.inc
的menu_get_item()
方法,
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 |
function menu_get_item($path = NULL, $router_item = NULL) { $router_items = &drupal_static(__FUNCTION__); if (!isset($path)) { $path = $_GET['q']; } if (isset($router_item)) { $router_items[$path] = $router_item; } if (!isset($router_items[$path])) { // Rebuild if we know it's needed, or if the menu masks are missing which // occurs rarely, likely due to a race condition of multiple rebuilds. if (variable_get('menu_rebuild_needed', FALSE) || !variable_get('menu_masks', array())) { if (_menu_check_rebuild()) { menu_rebuild(); } } $original_map = arg(NULL, $path); $parts = array_slice($original_map, 0, MENU_MAX_PARTS); $ancestors = menu_get_ancestors($parts); $router_item = db_query_range('SELECT * FROM {menu_router} WHERE path IN (:ancestors) ORDER BY fit DESC', 0, 1, array(':ancestors' => $ancestors))->fetchAssoc(); if ($router_item) { // Allow modules to alter the router item before it is translated and // checked for access. drupal_alter('menu_get_item', $router_item, $path, $original_map); $map = _menu_translate($router_item, $original_map); $router_item['original_map'] = $original_map; if ($map === FALSE) { $router_items[$path] = FALSE; return FALSE; } if ($router_item['access']) { $router_item['map'] = $map; $router_item['page_arguments'] = array_merge(menu_unserialize($router_item['page_arguments'], $map), array_slice($map, $router_item['number_parts'])); $router_item['theme_arguments'] = array_merge(menu_unserialize($router_item['theme_arguments'], $map), array_slice($map, $router_item['number_parts'])); } } $router_items[$path] = $router_item; } return $router_items[$path]; } |
$path
即我们传进去的q参数,经过一系列处理传给menu_get_ancestors()
方法,该方法把path重新组合成一堆router,也就是Drupal处理路由到具体url的传参方式,最终被db_query_range()
带入数据库查询
我们关注查询结果$router_item
的page_callback
值,因为这个值最终会作为参数被带入call_user_func_array()
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 |
if ($page_callback_result == MENU_SITE_ONLINE) { if ($router_item = menu_get_item($path)) { if ($router_item['access']) { if ($router_item['include_file']) { require_once DRUPAL_ROOT . '/' . $router_item['include_file']; } $page_callback_result = call_user_func_array($router_item['page_callback'], $router_item['page_arguments']); } else { $page_callback_result = MENU_ACCESS_DENIED; } } else { $page_callback_result = MENU_NOT_FOUND; } } |
到这里就跟8版本的情况有点类似了
跟入回调函数file_ajax_upload()
还是一样,把$form_parents
完整取出赋值给$form
,加上一些前缀后缀后最终进入drupal_render()
方法
最终得到执行
到目前为止我们分析清楚了为什么PoC要发两次包,以及第二次请求为什么要带上一个form_build_id
,现在来想一想为什么要请求user/password
这个路径呢?
在user这个module下的user_pass()
方法
1 2 3 4 5 6 7 8 9 10 11 12 13 14 |
function user_pass() { global $user; $form['name'] = array( '#type' => 'textfield', '#title' => t('Username or e-mail address'), '#size' => 60, '#maxlength' => max(USERNAME_MAX_LENGTH, EMAIL_MAX_LENGTH), '#required' => TRUE, '#default_value' => isset($_GET['name']) ? $_GET['name'] : '', ); ... return $form; |
看到这里是不是感觉跟8版本很相似,#default_value
从get的name
参数里取值,而name可以作为数组传入,它的属性在下面正好可以被利用,一个巧妙的利用链就串起来了。
Drupal 7.x的利用比8.x要复杂一些,但触发点和一开始的风险因素还是类似的,一是接收参数过滤不当,而是可控参数进入危险方法。官方补丁把入口处的#
全给过滤了,简单粗暴又有效,估计再利用框架本身的特性想传递进一些数组或元素就很难了。