Moodle 远程代码执行漏洞

原文首发:看雪论坛 http://bbs.pediy.com/thread-217212.htm 

简介

此漏洞(CVE-2017-2641)允许攻击者远程在 Moodle 服务器上执行 PHP 代码。它是由Moodle系统中几个组件中的不同漏洞组成的;我将在这篇文章里详细讲解每个漏洞的细节。

Moodle 是个非常流行的学习管理系统,部署在世界各地的许多学术机构,包括麻省理工、斯坦福、剑桥和牛津等著名大学。我选择检测 Moodle 主要就是因为它很受欢迎并存储大量敏感信息,如成绩、测试结果等学生的私人数据。

该漏洞只需任何高于游客的用户权限来利用(如学生,教师等),并影响目前部署的几乎所有 Moodle 版本。我强烈建议所有 Moodle 管理员立刻安装最新的安全补丁以解决这个问题。

受该漏洞影响的版本

3.2 到 3.2.1、 3.1 至 3.1.4、 3.0 到 3.0.8,2.7.0 到 2.7.18 和其他不受支持的版本。

技术细节

一、太多代码的隐患

Moodle 的系统极为庞大,包含数千个文件,数百个不同的组件,和约 200 万行的 PHP 代码。很明显,虽然它的各块代码可以彼此交互,也定是由很多不同的开发人员所写。

在这个文章中,我将示范为何太多代码,太多开发人员和缺乏规范的注释及文档会导致关键的逻辑漏洞。逻辑漏洞存在于几乎每一个代码库庞大的系统中,所以我所指出的这些安全问题影响和重要性远超 Moodle 系统本身。

我们可以从 Moodle 系统 Ajax 机制的实现中观察到“相同功能,不同代码”问题的清晰事例。

Moodle通过使用“外部函数”实现了一个动态的 Ajax 系统,允许不同的组件共同使用系统内置的 Ajax 接口。每个想使用 Ajax机制的组件都需要注册一个独立的“外部函数”,注明被调用的组件、函数名和调用所需的权限。当这些组件需要使用Ajax接口时,它们只需调用“service.php”文件并提供已经注册了的外部函数名就可以了。如此一来,各个组件的开发者都可以使用Moodle 内置的 Ajax 接口,省去了自己开发 Ajax 接口的麻烦。

但是,当 Moodle 核心部分的开发人员也开始使用这个接口时,问题就出现了。

不久之前,如果一个部件需要通过 Ajax 请求更改某个用户的首选项,则需调用“setuserpref.php”文件,并提供要更改的属性名称和新的值。

相关代码如下:

//   检查是否有权限。

if (!confirm_sesskey()) {

print_error('invalidsesskey');

}

//   获取要更改的首选项的名称,并检查其是否可被远程更改。

Get   the name of the preference to update, and check it is allowed.

$name = required_param('pref',   PARAM_RAW);

if (!isset($USER->ajax_updatable_user_prefs[$name]))   {

print_error('notallowedtoupdateprefremotely');

}

//   获取首选项的值

$value = required_param('value',   $USER->ajax_updatable_user_prefs[$name]);

//   更改值

if (!set_user_preference($name,   $value)) {

print_error('errorsettinguserpref');

}

echo 'OK';

从代码第二段落我们可以看到,Moodle在检查需要被更改的首选项是否存在“ajax_updatable_user_prefs”数组内,因为这个数组定义了哪些首选项可以通过 Ajax接口更改。这很合理,因为开发人员不希望用户通过 Ajax 接口恶意更改任何可能影响系统运作的关键首选项。

虽然大多数用户首选项就算不能通过 Ajax 接口也可通过其他方法更改,Moodle 的开发者们还是尽力在降低首选项机制被恶意利用的可能。

事实证明,他们对于首选项的细心防护的确很有效,直到某个马虎的开发者添加了一个新的外部函数“update_user_preferences”。添加这个新函数被的目的是取代一个旧的函数“update_users”。“update_users”的用途与“setuserpref.php”文件类似,也被用来更改用户首选项,但属于系统内部函数,不能通过Ajax接口调用。根据更新记录里的注释,“update_users”函数“可被用于更改任何用户属性”;这很显然存在安全隐患,因为一个只用来更改用户首选项的函数完全不需要有更改任何属性这么危险的功能。新的函数解决了这个问题–它仅可以更改用户首选项。这么看来,用“update_user_preferences” 替代“update_users”似乎是件有利无害的事。

但是,旧函数和新函数还有几个很重要的区别。由于其危险性,旧函数不能通过 Ajax 接口调用;但新函数可以,因为开发人员认为旧函数的危险功能(更改任何用户属性)已经在新函数中被移除了,所以允许它被其他组件调用。

为了安全,开发者还在新函数中增加了一道权限检查,防止当前用户更改其他用户的首选项。只可惜,他没能看到深层更大的安全隐患。如果这些首选项的值被用在eval 或 exec 函数中,那能否更改其他用户的首选项就根本无所谓了,因为攻击者可以直接在系统上执行自己的代码。

但讨论这些有些过早了,咱们先退几步分析一下这个函数是如何实现的:

public static function update_user_preferences($userid,   …, $preferences) {

...

// 如果用户在试着更改自己的首选项

if ($userid == $USER->id) {

//   检查是否有更改自己配置的权限

require_capability('moodle/user:editownmessageprofile',   $systemcontext);

}   else { // 否则用户就在试着更改其他人的首选项

/*   检查是否拥有管理员权限 如果没有则报错 这里的代码不重要 省略 */

}

//   更改用户的首选项.

foreach ($preferences as $preference) {

set_user_preference($preference['type'],   $preference['value'], $userid);

}

...

}

通过阅读这段代码我们可以看到,由于有权限检查,我们只能更改自己用户的首选项 。

即便如此,只要和之前提到的实现同样功能的“setuserpref.php”文件仔细对比一下,就不难发现这个新的函数漏了点什么。虽然新函数确保用户只能更改自己的首选项,但却没有检查用户更改的首选项是否应该允许通过Ajax更改。这和之前通过Ajax更改用户首选项的方法有着关键的区别。

这是不同开发者在不同的时间,有着不同目的的情况下写出实现同一个功能的不同代码的经典案例。

总结下来,在“update_user_preferences()”外部函数被添加,取代“update_users()”函数前,若是任何组件想通过Ajax更改用户首选项则需调用“setuserpref.php”文件。“update_user_preferences()”外部函数被添加后,组件也可以通过Ajax接口调用它来更改用户首选项。但是,与“setuserpref.php”不同,“update_user_preferences()”并不会检查所更改的首选项是否应该允许远程更改。这导致了本来密不透风的Ajax更改用户首选项机制悄然破了个大洞,但开发人员还错误地自认为用户首选项无法被攻击者恶意利用。

二、利用“无法被利用”的漏洞

用户首选项被开发者判定为无法被恶意利用是有一定道理的,不仅是因为之前更改它们的方法“setuserpref.php”密不透风。这些首选项本身似乎对于系统的运作影响甚微–

它们不被包括任何数据库查询中,也不定义任何组件;它们仅能够使图形用户界面(GUI)产生些许变化。

那么,我们到底能利用它做什么?

首先我们需要了解系统的几个 GUI 部分是怎么运作的。Moodle 的 GUI 将用户界面上各个组件信息分为独立的区块,并让用户以区块作为单元来自定义自己的界面。用户可以根据自己所需增加相关组件的区块,或移除不需要的区块。

其中一个名为“course_overview”(课程概述)的区块可以显示当前用户登记选修的课程。这些课程的排序存储于一个名为“course_overview_course_sortorder”的用户首选项中。这个首选项里存有所有当前用户登记的课程的课程编号。编号依时间排序,共同存储在一个由逗号分隔的字符串里。当课程概述区块需要显示课程排序的时候,会先执行这段代码将首选项里的课程编号从字符串拆分为数组,然后再按顺序显示给用户:

return explode(',', $value); //以逗号为分隔符拆分课程编号

但如果首选项内容为空呢?这种情况下,课程概述区块会试着通过一个遗留首选项,“course_overview_course_order”,来读取课程编号。这个首选项存有所有课程的编号,但储存和读取它们的方式与“course_overview_course_sortorder”不同。用来读取这个首选项的代码如下:

$order = unserialize($value); // 将课程编号反序列化

居然调用了unserialize()函数,我们中大奖了。

译者注

对于不熟悉 PHP和对象注入的朋友这里稍微做一下讲解,因为这个漏洞不算太常见。

unserialize()(反序列化)是个方便但危险的PHP函数,常与serialize(序列化)配合使用。serialize() 可以把一个任意类型的对象转换为字符串以方便储存,unserialize() 则把由serialize()转换的字符串转回PHP对象;对象的值、数据类型和结构在转换中都会保留。

若是被反序列化的字符串中存在未经正规过滤和编码的用户输入,则会导致对象注入漏洞,因为攻击者可以将任意数据注入进unserialize()函数返回的对象里。

想要更好地理解对象注入可以参考:http://blog.csdn.net/qq_19876131/article/details/50926210


这又是个因开发人员和代码缺乏规范性而导致安全漏洞的经典案例。它充分展现了遗留代码和向后兼容性带来的安全隐患,也再次反映了不同开发者会用不同方式来实现同样的功能这个现象。

对于我们来说,这意味着我们可以利用这个函数进行对象注入攻击。

由于 Moodle 对用户输入进行统一过滤,我们的注入受到些局限:

1.    我们不能注入任何空字节 。这也说明我们不能设置任何受保护或私有的对象属性,因为在PHP序列化它们时会在它们的序列化声明中加入空字节。

2.    虽然 Moodle 的代码库中有很多类,但绝大多数都是我们不可达的。它们不是在我们进行对象注入的作用域中没有被包含,就是无法通过自动加载功能来访问。

这些限制使得对象注入的难度提高了不少。不过别担心,我标题里都说了可以执行远程代码。咱们继续研究。

三、从对象注入中收获乐趣

趋于上述限制,我们只能使用已被包含的类的公共属性。我们也不能用任何依赖于受保护或私有属性的代码,因为它们被初始化为它们的默认值或着空。

这大大缩减了系统的可攻击面。几乎所有的类都会在某些情况下使用受保护的属性,而且大多数类根本不会有任何公共属性。

进攻的第一步是要先弄清我们具体能调用哪些 PHP 魔术方法。我们当然可以调用__wakeup()(当一个对象被反序列化时会调用)和__destruct() (当一个对象被摧毁时会调用),但我们能否调用另一个很常用的方法__toString()?

我们再次回到代码寻找一下线索。我们可以看到,我们注入的输入在被反序列化后成为了一个数组:

function block_course_overview_update_myorder($sortorder)   {

// $sortorder 是被反序列化的我们注入的输入,使用implode说明它是个数组

$value = implode(',',   $sortorder);

...

set_user_preference('course_overview_course_sortorder',   $value);

}

上面这个函数在试图把我们反序列化的数组的成员用逗号连接为一条长的字符串。但是,如果这个数组的某个成员是个对象,那它的类的__toString()魔术方法就会被调用。因为我们完全控制这个数组的值,所以想执行几个对象的__toString()方法就能执行几个。

但用__toString()又能做什么呢?我们先来看看“attribute_format”这个抽象类是如何实现它的__toString()方法的:

/**

*   将this转换成元素,再转换成字符串

*   @返回字符串

*/

public function __toString() {

return $this->determine_format()->html();

}

看起来很简单吧?我们再来看一个我们可以通过“feedback”类的“determine_format()”方法来访问的代码流。这个类继承自上面看到的“attribute_format”类:

/**

*   为这个用户界面元素制造一个text_attribute(文本属性)

* @返回text_attribute

*/

public function determine_format() {

return new text_attribute(

$this->get_name(),

$this->get_value(),

$this->get_label(),

$this->is_disabled()

);

}

这个方法调用了“is_disabled()”,让我们再来看看它是怎么实现的:

/**

*   根据其他配置判定这个输入点是否该被禁用

*   @返回boolean:这个输入点是否在页面加载时该被禁用

*/

public function is_disabled() {

...

if ($this->grade->grade_item->is_overridable_item()   …) {

$overridden = 1;

}

}

看到那个对“is_overridable_item()”的调用了吗?这又是个方法调用,但和之前看到的方法不同的是它作用于一个对象,而这个对象是调用它的对象的一个属性。

终于有些眉目了。由于我们控制“is_disabled()”的属性,所以也就控制了“is_overridable_item()”作用于的对象,这就大大扩展了我们的攻击面。调用“is_overridable_item()”方法的类之一是“grade_item”类。跟踪一系列方法调用后,我们到达了对“update()”方法的调用:

/**

*   返回这个成绩项目是否可被覆盖

*/

public function is_overridable_item() {

...

return ...   ($this->is_external_item() or $this->is_calculated()   ...);

}

/**

*   检查这个成绩是否计算成功并返回计算后的值。

*/

public function is_calculated() {

...

if (!$this->calculation_normalized   and strpos($this->calculation,   '[[') !== false) {

$this->set_calculation($this->calculation);

}

...

}

/**

*   如果当前项目的计算值没被设置,则创建它;如果已经设置了则更新它。

*   如果没有提供计算值,则删除储存的计算值。

*/

public function set_calculation($formula)   {

...

$this->calculation_normalized   = true;

return $this->update();

}

/**

*   利用对象的属性来更新数据库中的对象的值。编号必须已被设置。

*/

public function update($source=null) {

...

//   获取用来更新的数据,包括编号、列名称、值等

$data =   $this->get_record_data();

//   将数据库里的记录更新

$DB->update_record($this->table,   $data);

...

}

可以看到,“update()”方法负责用当前对象中的数据更新数据库;它利用对象自己的属性来更新由“table”属性指定的表。

到这一步,对于系统管理员来说游戏就基本结束了。通过我们的对象注入,我们可以更改整个数据库中的任何一行数据,包括更改管理员用户名、密码、网站设置等,基本可以为所欲为了。

但话虽如此,我们的注入还是受一些限制的:

1.    被我们注入的SQL更新语句由一个用于指定编号的WHERE条件结尾。

2.    我们无法在数据字段里注入新的SQL代码,因为我们所设置的值会被编。同时,我们所更新的字段名必须和数据库里的字段名匹配,所以我们也不能更改任何不在原有的SQL语句里的字段的值。

但我们为什么要在乎能否进行SQL注入?我们已经可以随意更改任何数据了,这就够了,对吧?

错了!我们的确可以随意更改任何数据,但却必须知道我们想更改的数据的行的编号。所以,如果我们想更改管理员的密码,那就只能去猜测管理员的用户编号,或者把数据库中所有账号的密码都一起改了。但作为一名有水准的黑客,我们一定要尽量减少我们攻击的误伤范围,追求精准度和隐匿度。

四、要做有水准的黑客

为了获得管理员权限而更改所有用户的密码实在是下下策。在理想的情况下,我们希望在不更改任何用户密码的情况下获得权限,因为更改密码太引人注目了。

为了避免更改密码,我们可以通过更改 config 表中 site_admins 的值自己添加一个新的管理员用户。但我们怎么能获得这个值在表中相应的编号呢?很可惜,我们不能。不过我们可以试着更改 SQL 代码中的 WHERE 语句,让它为我们所用。

为了达成这点,我们还是得用 SQL 注入。因为所有的数据字段都因被编码而不可被利用,我们只能把自己的 SQL 代码注入到不会被编码的表名称里。

但这又导致了一个新的问题–在 UPDATE 语句执行前,Moodle需要向数据库查询我们要注入的表的列名称和类型,而我们对表名称如果乱做改动就会导致这个查询失败。这意味着我们必须让相同的 SQL 注入同时作用于SELECT 和 UPDATE 语句上;前者应返回表的正确数据,后者则应更改“site_admins”的配置值。让我们来看一下这两个 SQL语句:

SELECT column_name, data_type, character_maximum_length,

numeric_precision, numeric_scale, is_nullable,

column_type, column_default, column_key, extra

FROM information_schema.columns

WHERE table_name =   '[TABLE_NAME]'

ORDER BY ordinal_position

UPDATE [TABLE_NAME] SET [DATA] WHERE id='[ID]'

很明显,这两个语句中唯一重叠的注入点就是表名称(代码中的 TABLE_NAME )了。

利用这个 SQL 注入的一种办法是在我们想更新的表名称后用“/*”开启一段多行注释,然后在某个数据参数中再将这段多行注释关闭。这样的话我们就可以在 UPDATE 语句的 SET 参数里插入一个自己的 WHERE 语句,用配置名称替代编号来过滤结果。

那么,被我们注入恶意代码后的 SQL 查询目前为止是这样的(“our_user_id”是我们的用户编号;“site_admins”是管理员成员表):

SELECT … WHERE table_name = 'config' /*'

ORDER BY ordinal_position

UPDATE config' /* SET field='*/ SET value=our_user_id WHERE name=site_admins-- WHERE id='[ID]'

很明显,目前情况下 UPDATE 语句不会执行成功,因为表名称“config”多了一个单引号。SELECT 语句也不会成功,因为 SQL 不允许我们开启一段多行注释但不关闭它。

但我们如何才能在表名称中注入一个注释,同时保证 SELECT 和 UPDATE 语句都正常运作呢?

那如果我们在表名称中插入一个多行注释但不将字符串关闭,然后加入一个 OR 语句会怎么样?我的意思是这样

SELECT … WHERE table_name = 'config /*' OR table_name LIKE '%config'

ORDER BY ordinal_position

UPDATE config /*' OR table_name LIKE '%config SET field='*/ SET value=our_user_id WHERE name=site_admins-- WHERE id='[ID]'

这里的 SELECT 语句比较易懂,但 UPDATE 语句可能需要一些解释。MySQL会这样理解 UPDATE 语句:

UPDATE config /**/ SET value=our_user_id WHERE name=site_admins–-

现在可以清晰地看到,我们把表名称和我们控制的第一个数据值之间的内容都注释掉了。这样我们就可以在不影响 SELECT 语句的情况下随意使用 UPDATE 语句更改数据库里的内容了。

利用这个办法,我们可以把SET的参数更改为我们的用户编号,然后通过我们自己插入的WHERE语句过滤出管理员成员表,再加上单行注释符“--”注释掉原有的 WHERE 语句,就能把我们的用户加为管理员了。我们成功在两个不同语句中同时利用了同一个 SQL 注入。

接下来我们只需等待配置缓存更新(大概每天都会更新一次)或者通过清空“allversionhash”的值强迫缓存更新就可获得管理员权限了。“allversionhash”存储着系统中所有核心文件的SHA-1散列值,当被清空时Moodle 会判定系统刚完成了固件更新,并因此更新所有缓存。

获得管理员权限后,只需通过上传插件或者模板就能执行代码了。

通过利用开发人员代码缺乏规范化产生的疏漏、一个对象注入漏洞、一个双重 SQL 注入漏洞和一个权限过于宽松的管理员界面,我们终于胜出。我们在服务器上成功远程执行了自己的代码。

本文由 看雪翻译小组 buusc 编译,来源Netanel Rubin@0-Days And Life

往期热门内容推荐

等你来挑战!| 看雪 CTF 2017 攻击篇

【终于等到你!】看雪 CTF 2017

Janus 移动分析平台正式发布 appscan.io!

应用 Bro 软件对 TLS 客户端进行指纹识别

深圳,一个让我彷徨的“天堂” —— 喜当爹

......

更多优秀文章,“关注看雪学院公众号”查看!

看雪论坛:http://bbs.pediy.com

微信公众号 ID:ikanxue

微博:看雪安全

投稿、合作:www.kanxue.com

你可能感兴趣的:(Moodle 远程代码执行漏洞)