原文地址:https://zhuanlan.zhihu.com/p/26134332
[作者:phithon,现就职于长亭科技,参与Pwnhub的开发与运营,长期关注并笔耕于安全编码、代码审计等方向]

Pwnhub( pwnhub | Beta ),中文解释‘破解中心’,谐音胖哈勃,一个以各种安全技术为内容的竞赛平台。从去年12月初上线到现在,已经稳定运行了4个月,主办了10场比赛。作为一篇安全技术向的文章,我们不谈情怀,不谈运营,单说说Pwnhub的开发,我遇到过哪些(安全)暗坑,以及怎么去规避。

本文主要从微信模块安全、API安全、权限控制、前端安全、商城逻辑安全、运维安全等几个方面来介绍Django的安全编码。

由于我们公司的 Web 技术栈是 Vue+Django,采用纯 SPA 异步通信模式,选型上自然没有任何难度,很快前后端配合开始了工作。

微信模块安全

Django框架的优势之一就是其自带的用户模块,我们可以用少量代码搭建整体用户逻辑,包括用户登录、找回密码、权限控制等。但因为Pwnhub在选型的时候就确定使用微信登录,所以用户逻辑方面势必需要进行一些改造。

我的做法是前台砍掉Django的用户登录大部分所有逻辑,包括:注册、登录、找回密码、修改密码,仅留下一个退出登录,然后自己做了微信登录相关的代码逻辑。

任何一个与微信相关的应用,想保证与微信间的通信,需要注意以下4个变量:

  1. 应用ID AppID
  2. 应用密钥 AppSecret
  3. 通信令牌 Token
  4. 消息加密密钥 EncodingAESKey

其中,必须配置的前三者,后者可以不用配置;必须保密的是后三者,前者可以公开。

应用IDAppID和应用密钥AppSecret是我们服务器和微信服务器通信的凭证。举个例子,因为Pwnhub需要扫码登录,所以我得向微信服务器提出这个需求,并拿到二维码图片。那么,微信服务器信任这个请求的前提就是我需要带上access_token访问,而access_token是一个临时凭证(有效期2小时),拿到这个临时凭证的方式就是利用AppID和AppSecret。

AppID是告诉微信服务器“我是谁”,而AppSecret是证明我是“这个人”的凭证,微信确认了这二者的对应关系后,就会给我发放一张有效期为2小时的“通行证”。以后出入微信服务器都应该带上这个通行证,也就是access_token;如果过期,用相同的方法再次获取。

通信令牌Token是微信服务器访问我们服务器的凭证。这就涉及到HTTP协议的特点,因为HTTP协议是一个无状态的协议,所以,虽然我向微信服务器证明了我是谁,但是并不能建立一个“通信通道”,所以当微信服务器需要访问我的时候,我还需要验证他的身份。

Token相当于是一个签名密钥,微信服务器在访问我之前,会生成一个随机数,并和当前时间戳、Token一起使用sha1签名后发送给我的服务器。我需要对其进行校验,如果签名不正确的会就返回错误。

消息加密密钥EncodingAESKey是微信后来推出,以确保通信信息不被中间人窃取的方式。因为微信公众号使用者的服务器不一定配置了https,所以通信过程中可以选择使用EncodingAESKey来加密消息内容。

举个例子,用户、Pwnhub与微信服务器间通信流程如下:

这个过程存在着几个经常被开发者忽视的问题:

AppSecret、Token泄露是老问题了,很多开发者将自己的代码直接上传到github等第三方代码托管平台,而没有删除此类敏感信息;即使用户及时发现并删除了信息,git的特性也导致攻击者可以在以往提交的记录中找到这些信息。好在AppSecret和Token都能重置,一旦发现有泄露的情况,第一步应该是重置这两个配置。

时间戳不验证,这个问题在微信公众号示例代码中就存在:

<?php
...
public function decryptMsg($msgSignature, $timestamp = null, $nonce, $postData, &$msg)
{
    if (strlen($this->encodingAesKey) != 43) {
        return ErrorCode::$IllegalAesKey;
    }

    $pc = new Prpcrypt($this->encodingAesKey);

    //提取密文
    $xmlparse = new XMLParse;
    $array = $xmlparse->extract($postData);
    $ret = $array[0];

    if ($ret != 0) {
        return $ret;
    }

    if ($timestamp == null) {
        $timestamp = time();
    }

    $encrypt = $array[1];
    $touser_name = $array[2];

    //验证安全签名
    $sha1 = new SHA1;
    $array = $sha1->getSHA1($this->token, $timestamp, $nonce, $encrypt);
    $ret = $array[0];

    if ($ret != 0) {
        return $ret;
    }

    $signature = $array[1];
    if ($signature != $msgSignature) {
        return ErrorCode::$ValidateSignatureError;
    }

    $result = $pc->decrypt($encrypt, $this->appId);
    if ($result[0] != 0) {
        return $result[0];
    }
    $msg = $result[1];

    return ErrorCode::$OK;
}

可见,验证安全签名相等以后就直接解密,那么,时间戳timestamp就没有任何意义了。正确的做法应该是,在验证签名相等的同时,检查当前时间和时间戳的差是否在一定范围内。

EncodingAESKey是保证服务器和微信公众号之间的通信不被第三方监听。如果你得服务器没有配置HTTPS证书,就应该正确配置该值。

除此之外,由于微信公众号是用XML进行通信,所以难免会遇到XML安全相关的问题:虽然保证Token不泄密的情况下,第三方用户无法对服务器进行请求;但极端情况下,我们是不应该相信任何用户的输入,即使该用户是微信服务器。

XXE等XML中常见的安全问题我就不多说了,我使用Python的模块defusedxml( defusedxml 0.5.0 )来确保XML解析时候的安全。

API安全

由于是前后端分离项目,Pwnhub前台部分全部使用API通信。API安全是Pwnhub整体安全的重中之重,应该API的通信可以看做是动作和数据的交换,一旦有没有配置好的部分,将可能导致水平权限绕过和信息泄露等漏洞。

Pwnhub后端使用Django-Rest-Framwork开发(后文用DRF简称),DRF是一个基于Django的API扩展库,我们通过其内置的Serializer概念,即可很方便地控制Model中“我想让用户看到的属性”和“用户可以控制的属性”。

前段时间,有个朋友来问我,DRF的Serializer会不会导致反序列化相关的漏洞。其实很多人误会了此处Serializer的角色,虽然它有这样一个容易被人误解的名字,但Serializer实际作用和原生Django中的Form类似,主要作用是控制用户输入的内容,并进行校验。但有一个不同点是,Serializer还能控制输出信息的范围。

我们可以把Serializer理解为一个Model和View之间的媒介,View将用户输入的信息发送给Serializer,Serializer进行筛选和校验,成功后发送给Model,保存进数据库;Model从数据库中取出信息交给Serializer,Serializer进行筛选,只显示开发者允许显示的内容,并交给View,Views显示出来。

在代码中,我们通过在Serializer里定义fields,来限制允许显示或修改的属性:

class UserSerializer(ModelSerializer):
    class Meta:
        model = User
        fields = [
            'id', # 用户id
            'username', # 用户名
            'email', # 邮箱
            'password', # 密码
            'rank', # 积分
            'coin', # 金币
            'mugshot', # 头像
            'tag', # 用户擅长方向
            'introduction', # 个人介绍
            'signature', # 签名
            'date_joined' # 注册时间
        ]
        read_only_fields = [
            'id',
            'username',
            'email',
            'rank',
            'coin',
            'date_joined'
        ]

上述代码是一个简单的用户Model对应的Serializer,可见,我通过定义fields,列出程序需要的有哪些字段;再通过定义read_only_fields,来限制哪些是只读字段。

也就是说,用户id、用户名、邮箱、用户积分、金币和注册时间是不允许修改的。

但上述代码有一个很明显的问题:虽然限制了哪些字段是只读的,但没有限制哪些字段是只写的。

如果使用DRF的内置View,在每次创建或更新一个Model对象后,会自动显示修改后的Serializer,也就是fields里所有的字段值。但实际上有些字段是不太适合显示的,比如password,这个字段应该定义为“只可写而不允许读”:

extra_kwargs = {
  'password': {'write_only': True}
}

这个细节是开发很容易忽视的,如果设计不好,将很可能导致类似前几天Gitlab出现的任意用户敏感信息泄露漏洞(CVE-2017-0882)。

不过,DRF里定义write_only和read_only的方式确实比较奇葩,read_only有简单写法read_only_fields,但write_only则没有。

权限控制

DRF有一套自定义性很高的鉴权机制。

因为其为一个API框架,所以面向的前端可能是浏览器,也可能是手机APP,所以常规Web中以Cookie和Session授权的方法就不一定有效了。

DRF内置了三种授权(authentication)方法:HTTP基础认证、Session、Token。HTTP基础认证适合于纯浏览器的前端环境,Session适合于浏览器或基于浏览器内核的应用程序,Token适合无浏览器环境的应用程序。

用户也可以定义自己的授权方法,并任意搭配它们。举个例子,Pwnhub现在使用的是Session进行授权,以后如果要开发手机App,可能会增加基于Token的授权方法。

通过授权方法,DRF给每个请求标记上一个用户(未登录的用户被称为匿名用户AnonymousUser),但该用户是否有权限访问某个API,这就得问“许可模块”(Permission)了。

也就是说,Authentication模块只负责给请求“发通行证”,Permission模块负责检查这个“通行证”是否有权限访问某个地点。

不同的API View可以选择使用不同的Authentication和Permission,根据自己需要的访问权限任意搭配,也可以使用全局默认值。Pwnhub前台权限很简单,用Session授权,并只有两种Permission:登录用户和匿名用户,所以配置起来也比较简单。

另外,权限方面DRF还提供了一个更高级的模块:Throttling。使用这个模块,即可定义某个“用户”访问某个API的频度。举个例子,某视频网站允许每个免费用户每天观看5个视频,就需要经过如下步骤:

Authentication:Session认证 --> Permission:是否是已登录用户 --> Permission:是否是免费用户 --> Throttling:是否已观看5个视频 --> View:返回视频地址

当然,Pwnhub的功能暂时还没有这么复杂。

前端安全

前端安全是不可绕过的一个话题。近年来前后端分离的开发模式一定程度上减少了后端代码直接输出用户输入导致的XSS等前端漏洞,但同时也催生了一种新的攻击方式:在前后端没有完全分离的情况下,将可能出现“客户端模板注入漏洞”(Client-Side Template Injection )。

这种攻击方式可以参考这篇文章( XSS without HTML: Client-Side Template Injection with AngularJS ),本文就不对其进行介绍了。本文从4个切入点来说说Pwnhub可能面临的前端漏洞。

XSS in Vue

Pwnhub前端基于Vue框架,开发者可以通过数据绑定的方式,很容易地在前端显示从后端获取的数据,而且这个过程是相对安全的:因为在将数据输出到模板的时候,Vue默认会将数据识别为纯文本,而非HTML代码。

但并不是所有位置的输出都适合纯文本,也不是所有程序员都能正确理解他需要编写的逻辑。

举个例子,如果产品经理希望用户评论能包含图片、超链接,那么前端页面在输出评论内容的时候就不能对其进行转码。在Vue中需要使用v-html指令:

<div v-for="comment in $store.state.comments" :key="comment.id">
    {{ comment.username }}: <div v-html="comment.content"></div>
</div>

见上述代码,comment.username是评论者名称,它被包裹在双大括号内,默认识别为纯文本;而comment.content放在v-html指令中,将会被识别为HTML代码。那么,一旦用户提交恶意HTML评论,后端没有处理的情况下,将造成XSS漏洞。

如果你有这个需求,可以使用我编写的python-xss-filter( phith0n/python-xss-filter )在后端对评论内容进行处理。

XSS in Django

由于Python运行模式的特殊性(由某一入口点统一进入),上传文件后缀通常也不会卡的太死,只要不覆盖到程序本身的py文件,都不会有太大问题。所以,Django本身很多操作默认是不检查后缀的,比如我们定义一个model,其中包含一个ImageField():

class Attachment(models.Model):
    name = models.CharField('名称', blank=True, null=True, max_length=256)
    photo = models.ImageField('图片', blank=True)
    ...

很多人认为,ImageField是Django提供的图片字段,按理说Django自己应该已经做了细致的检查。但实际上Django只检查用户上传的文件内容是否是图片,而不会检查文件后缀是否是图片后缀。

但服务器中间件和浏览器并不会根据文件内容来判断一个文件的mime_type,而是根据后缀。所以,我只需要上传一个文件内容符合GIF格式,而后缀是.html的文件,即可构造一个XSS漏洞。

访问a.html,成功执行:

假如当前服务器支持SSI或PHP等语法,那么这个问题可能更会上升成一个服务端漏洞。

解决这个问题的方法是,在ImageField字段上增加validators,如photo = models.ImageField('图片',validators=[check_image_extension], null=True, blank=True),check_image_extension函数里对文件后缀进行检查即可。

CSRF漏洞

CSRF漏洞在Django中有默认的检查:所有POST, PUT, PATCH和DELETE请求都会检查CSRF Token。但Django对CSRF Token检查机制是,将Cookie中的Token和表单或HTTP头中的Cookie进行比对,以避免请求被恶意伪造。

这就和DRF的设计理念有些出入了:DRF面向的前端可以是浏览器,也可能不是浏览器,Cookie并不是一个必要的值。

所以,DRF在APIView中强制关闭了Django原生的CSRFCheck(csrf_exempt(view)):

class APIView(View):
    ...

    @classmethod
    def as_view(cls, **initkwargs):
        """
        Store the original class on the view function.

        This allows us to discover information about the view when we do URL
        reverse lookups.  Used for breadcrumb generation.
        """
        if isinstance(getattr(cls, 'queryset', None), models.query.QuerySet):
            def force_evaluation():
                raise RuntimeError(
                    'Do not evaluate the `.queryset` attribute directly, '
                    'as the result will be cached and reused between requests. '
                    'Use `.all()` or call `.get_queryset()` instead.'
                )
            cls.queryset._fetch_all = force_evaluation

        view = super(APIView, cls).as_view(**initkwargs)
        view.cls = cls
        view.initkwargs = initkwargs

        # Note: session based authentication is explicitly CSRF validated,
        # all other authentication is CSRF exempt.
        return csrf_exempt(view)

并将CSRF检查放在了SessionAuthentication中,TokenAuthentication和BasicAuthentication中是没有CSRF检查的。(当然也不需要检查,因为Token或401账号密码都是攻击者不能获取的)

如果是开发者自己定义Authentication,那么CSRF验证这块就需要格外注意了。

另外,DRF只检查登录用户的CSRF Token,这个特点在大部分情况下都没有问题,因为非登录用户也基本不存在请求伪造的必要了。但有一种情况,就是用户登录的操作,我们需要手工给这些方法加上@method_decorator(ensure_csrf_cookie)用来生成CSRF Token,并进行手工检查(如果你使用Django自带的login view并开启了CSRF全局校验,就无需手工检查了)。

Pwnhub会在前端获取Cookie中的CSRF Token,并附加到所有HTTP请求中,以保证正常的流程不会被拦截。

Json Hijacking

后端API返回中可能包含一些敏感信息,那么,如何在前端保证这些数据不被其他人窃取?

理论上,浏览器的跨域原则足够防御这一类攻击了,但是如果后端返回的数据被浏览器“错误”地认为是一个合法的JavaScript或css数据,那么就可能造成信息泄露攻击了,这也是Jsonp劫持等很多跨域漏洞的原理。

DRF在输出层,提供了一个叫Renderer的概念,也就是输出数据的渲染方式。通常在开发环境下,为了调试方便,我会使用BrowsableAPIRenderer,这个渲染方式会提供一个Web页面用来输入和显示数据:

但如果需要和前端配合使用的时候,就需要用到JSONRenderer了。它会将Serializer输出的数据转换成Json再返回给前端框架:

见上图,虽然实际我们需要传递的数据是一个数组["PWN","REVERSE","CRYPTO","MISC","WEB"],但Pwnhub对数据进行了一层包裹,那么这层包裹对改进安全性上有什么帮助?

这就涉及到Json劫持漏洞了(区分json劫持和jsonp劫持)。众所周知,Json是可以被JavaScript原生支持的,所以,如果我们使用<script>标签去加载我们的API返回结果,将会有一些奇妙的反应:

  1. 如果Json API返回的是一个对象,将抛出Uncaught SyntaxError: Unexpected token :异常,因为浏览器会认为它并不是一个合法的JavaScript对象(为什么?)
  2. 如果Json API返回的是一个数组,浏览器将不会抛出异常

所以,如果我返回了第二种情况,攻击者通过对JavaScript数组对象的劫持,将可能可以窃取数组中的数据。

关于最新版浏览器的JSON Hijacking,可以参考这篇文章: JSON hijacking for the modern web

Django自带的JsonResponse默认会检查用户传入的数据是否是一个字典,因为只有字典对应到Json中才是对象,相对安全:

class JsonResponse(HttpResponse):
    """
    An HTTP response class that consumes data to be serialized to JSON.

    :param data: Data to be dumped into json. By default only ``dict`` objects
      are allowed to be passed due to a security flaw before EcmaScript 5\. See
      the ``safe`` parameter for more information.
    :param encoder: Should be an json encoder class. Defaults to
      ``django.core.serializers.json.DjangoJSONEncoder``.
    :param safe: Controls if only ``dict`` objects may be serialized. Defaults
      to ``True``.
    :param json_dumps_params: A dictionary of kwargs passed to json.dumps().
    """

    def __init__(self, data, encoder=DjangoJSONEncoder, safe=True,
                 json_dumps_params=None, **kwargs):
        if safe and not isinstance(data, dict):
            raise TypeError(
                'In order to allow non-dict objects to be serialized set the '
                'safe parameter to False.'
            )
        if json_dumps_params is None:
            json_dumps_params = {}
        kwargs.setdefault('content_type', 'application/json')
        data = json.dumps(data, cls=encoder, **json_dumps_params)
        super(JsonResponse, self).__init__(content=data, **kwargs)

如上,safe默认为True,在这种情况下,data必须为dict。

商城逻辑

2017年3月初,Pwnhub上线了一个简单的商城功能,参与比赛的用户可以用自己在比赛中获得的积分兑换礼品。新功能的上线,势必引入新的安全威胁,因为Pwnhub的商城交易纯属虚拟货币逻辑,并不复杂,所以除了传统的水平权限漏洞以外,商城逻辑中最容易被忽视的漏洞其实是条件竞争。

条件竞争漏洞已经多次发生在我们周围了,最近一次是小密圈在2017年2月下旬出现的用户超额提现的问题( 小密圈产品事故@2017 No.2 )。

Pwnhub商城中可能出现条件竞争漏洞的地方有两处:

  1. 用户购买某商品时,余额判断后的条件竞争
  2. 用户购买多件商品时,商品数量判断后的条件竞争

有的同学可能还不太理解一个商城购买商品的逻辑,我以Pwnhub为例画了个简图:

所以,上述逻辑攻击者有两种攻击方式:

  1. 某一个用户同一时间对不同商品发送多次购买请求,这些请求都通过了“用户余额判断”,该用户余额被扣成负数,相当于使用它没有的钱购买了多件商品
  2. 不同用户同一时间对同一商品发送多次购买请求,这些请求都通过了“商品剩余数量判断”,结果导致商品数量被买成负数,相当于买到了无货的商品

举个例子,A用户拥有10个金币,刚好购买价值8金币的商品B。这种情况下,A用户开启10个线程同时发送购买商品请求,在数据库尚未修改的情况下,这10个请求均发现A的余额是足够购买B商品的,然后扣除8金币并成功下单。此时A用户的金币数量为10 - 8 * 10 = -70,但10个订单均已经成功支付了。

针对类似的攻击,大部分数据库通常都提供了某个记录加锁的功能,也就是在查询某条记录的时候使用SELECT FOR UPDATE,在Django ORM里使用.select_for_update()语法。

with transaction.atomic():
    # 获取用户和商品对象
    user = User.objects.select_for_update().get(pk=self.request.user.pk)
    gift = Gift.objects.select_for_update().get(pk=serializer.validated_data['gift'].pk)

    # 检查用户余额和商品数量
    serializer.atomic_validate(user, gift)

    # 下订单
    order = serializer.save(user=self.request.user)

    # 扣除用户余额
    user.coin -= order.cost
    user.save()

    # 扣除商品数量
    gift.amount -= order.amount
    gift.save()

另外,商城逻辑容易出BUG的地方还有几个点:

  1. 下单和支付逻辑不同步的问题
  2. 购买商品数量是负数的情况
  3. 前端传入商品价格的情况

第一个问题基本不存在了,因为Pwnhub是前后分离,下单操作可以在前端完成,后端只要接收到“购买(支付)”请求,就立马扣款。

第二、三个问题,我们在Serializer中对其进行校验即可:

class BuySerializer(serializers.ModelSerializer):
    amount = serializers.IntegerField(label='数量', min_value=1, max_value=10)

    class Meta:
        model = models.GiftLog
        fields = [
            'order_no',
            'gift',
            'address',
            'remark',
            'amount',
        ]
        read_only_fields = ['order_no']

解决第二个问题的方法是在IntegerField里增加min_value,解决第三个问题的方法是……fields中根本没有商品价格,因为商品价格是从gift对象的price中获取的。

上线前夕

做了那么一大堆工作,如果部署的时候稍有不慎,可能前面的安全工作都白做了。

部署的时候应该从如下三个切入点考虑:

  1. Django框架安全部署
  2. Django-Rest-Framework框架安全部署
  3. Web容器安全部署

Django框架安全部署

Django考虑的非常周到,在项目上线前,我们可以通过执行./manage.py check --deploy命令来查看可能存在的安全问题:

可见,默认生成的项目,存在以上安全问题,在部署前需要解决:

  1. SECURE_HSTS_SECONDS 是否开启HSTS头,强制HTTPS访问
  2. SECURE_CONTENT_TYPE_NOSNIFF 是否输出nosniff头,以防止类型混淆类漏洞
  3. SECURE_BROWSER_XSS_FILTER 是否输出x-xss-protection头,让浏览器强制开启XSS过滤
  4. SECURE_SSL_\REDIRECT 是否让HTTP的请求强制跳转到HTTPS
  5. SESSION_COOKIE_SECURE 是否将Cookie设置为Secure(不允许在HTTP中传输)
  6. CSRF_COOKIE_SECURE 是否将CSRF Token Cookie设置为Secure(不允许在HTTP中传输)
  7. CSRF_COOKIE_HTTPONLY 是否将CSRF Token Cookie设置为HTTP ONLY
  8. X_FRAME_OPTIONS 是否返回X-FRAME-OPTIONS: DENY头,以防止被其他页面作为框架加载
  9. DEBUG 是否开启调试模式

其中,DEBUG是必须要关闭的。其他的选项主要看你对安全的要求了,例如如果你得页面不强制使用HTTPS,那么其中的一些防止中间人劫持的选项就没必要设置。

另外,上线前,最好修改后台地址,如果你不需要使用Django自带的后台,可以在INSTALLED_APPS里将其移除。SECRET_KEY重新进行生成,不要和开发、测试环境相同:openssl rand 10 -out .secret。

Django-Rest-Framework框架安全部署

前文已经说了,开发环境中DRF通常使用BrowsableAPIRenderer,但在部署到生产环境后,最好将这个Renderer移除,只保留我们需要用到的JSONRenderer:

REST_FRAMEWORK = {
    'DEFAULT_RENDERER_CLASSES': (
        'rest_framework.renderers.JSONRenderer',
        #'rest_framework.renderers.BrowsableAPIRenderer',
    ),
    ...
}

因为攻击者可以利用BrowsableAPIRenderer输出的表单和返回结果,很容易地进行测试和攻击,也可能是DRF本身出现的前端安全漏洞(如XSS),会影响到你。

另外,DRF自带一个用户登陆View( 4 - Authentication and permissions - Django REST framework ),如果你不想用户被爆破的话,也请检查自己的urls.py,是否添加了这个View。

Web容器安全部署

这个就是常规话题了,按照服务器正常加固方法加固即可。

因为Pwnhub部署在Docker中,所以我们需要用Nginx在前端进行转发,但Django中有些需要获取IP的方法(Django有个配置叫INTERNAL_IPS,如果访客IP在这个配置项中,将可能可以享受一些DEBUG模式才有的功能),我使用了HTTP_X_FORWARDED_FOR这个头,那么在Nginx中就必须覆盖用户提交的头,否则就有可能造成任意IP伪造漏洞。

总结

作为一个小团队,虽然很难像微软等大公司这样去实现完整的SDL,但是针对Pwnhub这样小规模的项目,通过学习上述的安全编码知识,我们也能最大程度保障这个项目不被恶意攻击。如果大家有一些安全编码相关的想法,欢迎与我们讨论。


源链接

Hacking more

...