BurpSuite插件编写[初级篇]

0x00 前言

上一篇文章主要是介绍burp插件的基本情况,对插件编写有一个入门的了解,本篇将以调试分析的角度,记录一下学习方法和编写流程。

0x01 debug 前期文件准备

在编写插件代码的时候,若不进行一步步的调试的话,只能是凭空造轮子,耗时耗力。

因为我们大部分测试者都是使用的破解版,所以在配置调试的时候因为某些原因是不太适用的,故需要下载一个社会版。这里我已经1.7.26版本,2.0的版本的文件太大了。

2

下载地址:https://portswigger.net/burp/releases/professional-community-1-7-26

网盘版地址:链接: https://pan.baidu.com/s/10rwx1eWdNCEQWqw4KpqtEw 提取码: xtnt

正因为我们使用的社区版,所以在挑选现成的burp插件的时候,需要避开那些要使用到专业版或者企业版的API的插件。

这里我以 request-timer 插架为dome开始进行分析,该插件的功能介绍如下:

Burp Request Timer

此扩展捕获所有Burp工具发出的请求的响应时间。在发现潜在的定时攻击中可能很有用。

可以通过工具(代理,扫描仪,入侵者中继器),目标作用域以及与URL中的字符串进行匹配来过滤结果。

需要Java 8。

选用该插件作为分析的原因还有一个就是,几个月前,看到 ch1st 写了一个插件,就是基于被动或者主动识别存在java各种反序列化特征的插件,而现在这个大体上都差不多。

0x02 debug 操作流程

下载好上面的那个插件源码后,因为github上没有直接附上成品和相应API接口文件,所以需要我们自己导入到相应文件中。

3

整理好后,直接打包成jar文件即可。

1、首先我们使用以下命令打开burp社区版

1
java -agentlib:jdwp=transport=dt_socket,server=y,suspend=n,address=8888 -jar burpsuite_free_v1.7.26.jar

4

该命令启动的burp,是便于我们进行远程调试的,即开启了远程调试端口。

2、我们导入刚才编译好的jar插件。

5

3、这个插件的页面是这个样子的。

6

4、我们开始设置eclipse中的远程调试配置。

4.1、运行→调试配置

7

4.2、选择 远程Java应用程序→鼠标右键→新建

8

4.3、名称可以随便设置,主要需要设置好端口号和启动burp的端口号一致,剩下的点击应用和调试即可。

9

4.3、选择IDE右上角的调试界面按钮,我们就能看到整个运行过程。

10

4.4、我们先下个断点,看看效果吧。

4.5、我们在99、100、101这三行加了断点。

11

4.6、在burp界面一顿操作啊操作(让插件进行相应功能),然后我们就可以看到调试界面显示的参数了。

12

我们可以看到 toolFlag 这个接受到的参数是 4,具体这个参数4是什么意思,需要去翻看API手册中即可。

注:需要注意的是,编译的 jar 中的代码必须和我们进行调试的代码一模一样,这样在远程调试的时候,不会出错。

0x03 Burp Request Timer 源码分析

先写一个好的插件,就需要参考前辈们的代码,既减少了重复造轮子,也学习了前辈们编写的思路。

首先我们看一下这个源码函数的大致UML图:

源码整体函数可以分为三个部分,分别是:

  • 插件主体逻辑部分:BurpExtender
  • UI界面代码部分:MainPanel
  • 功能数据存放部分:LogTablemodel、LogTable、Log

由于源码继承问题,我从下往上进行分析(功能数据存放部分→UI界面代码部分→插件主体逻辑部分)。

0x04 功能数据存放部分 Log

源码示例:

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 burp;

import java.net.URL;
import java.time.LocalDateTime;

/**
* 请求获取的相关数据对象
*/
class Log {

final LocalDateTime timestamp;
final String tool;
final IHttpRequestResponsePersisted requestResponse;
final URL url;
final long time;
final short status;
final String mimeType;

/**
* 构造函数
*
* @param timestamp 请求时间戳
* @param tool tool来源
* @param requestResponse HttpRequestResponse获得的对象
* @param url 请求连接
* @param status HTTP设置
* @param mimeType 未知的MIME类型
* @param time 时间taken
*/
Log(LocalDateTime timestamp, String tool, IHttpRequestResponsePersisted requestResponse, URL url, short status, String mimeType, long time) {

this.timestamp = timestamp;
this.tool = tool;
this.requestResponse = requestResponse;
this.url = url;
this.time = time;
this.status = status;
this.mimeType = mimeType;
}
}

整体类函数功能:

该代码部分主要是把相应字段信息以常量参数的形式进行储存,便于数据的调用和展示,而Log类主要被 LogTableModel类使用。

0x05 功能数据存放部分 LogTableModel

源码示例:

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

import java.util.ArrayList; // 可调整大小的数组的实现List接口类库
import java.util.List; // 有序集合类库
import javax.swing.table.AbstractTableModel; // 此抽象类提供默认实现大部分的方法TableModel接口

/**
* 存放日志表的对象类
*/
public class LogTableModel extends AbstractTableModel {

private final java.util.List<Log> logArray = new ArrayList<>();

@Override
public int getRowCount() {
/* 抽象类 AbstractTableModel 必须要重写的函数
* 返回列表内元素个数
*/

return logArray.size();
}

@Override
public int getColumnCount() {
/* 抽象类 AbstractTableModel 必须要重写的函数
* 返回列数,固定为6个字段
*/

return 6;
}

@Override
public String getColumnName(int columnIndex) {
/* 非强制需重写的方法函数
* 使用电子表格约定返回列的默认名称
*/

switch (columnIndex) {
case 0:
return "Timestamp";
case 1:
return "Tool";
case 2:
return "Request URL";
case 3:
return "MIME Type";
case 4:
return "Response Time (ms)";
case 5:
return "HTTP Status";
default:
return "";
}
}

@Override
public Class<?> getColumnClass(int columnIndex) {
/* 非强制需重写的方法函数
* 返回当前列的对象类型
*/

switch (columnIndex) {
case 0:
return String.class;
case 1:
return String.class;
case 2:
return String.class;
case 3:
return String.class;
case 4:
return Long.class;
case 5:
return Short.class;
default:
return Object.class;
}
}

@Override
public Object getValueAt(int rowIndex, int columnIndex) {
/* 抽象类 AbstractTableModel 必须要重写的函数
* 返回行列中选定的值
*/

Log logEntry = logArray.get(rowIndex);
// 通过行值进行在 ArrayList 中选定最终的对象,然后使用下列switch语法选中值

switch (columnIndex) {
case 0:
return logEntry.timestamp;
case 1:
return logEntry.tool;
case 2:
return logEntry.url.toString();
case 3:
return logEntry.mimeType;
case 4:
return logEntry.time;
case 5:
return logEntry.status;
default:
return "";
}
}

/**
* Returns the <code>Log</code>
*
* @return <code>List<Log></code>
*/
List<Log> getLogArray() {
/*
* 返回所有数据的对象值列表
*/

return logArray;
}

}

整体类函数功能:

该类的构造函数是生成了一个 ArrayList() 的列表,而列表的类型为 Log,然后分别创建或重新了以下几个函数:

  1. getRowCount():获取列表内元素的个数,该函数是抽象类 AbstractTableModel 强制要重写的函数。
  2. getColumnCount():返回字段的列数,由于我们前期就设置了6个固定字段(Timestamp、Tool、Request URL、MIME Type、Response Time (ms)、HTTP Status),该字段值数是与UI页面显示字段数相一致的,该函数是抽象类 AbstractTableModel 强制要重写的函数。。
  3. getColumnName(int columnIndex):该函数主要是根据固定值返回对应字段的名称。
  4. getColumnClass(int columnIndex):该函数主要是根据固定值返回对应字段的对象类型。
  5. getValueAt(int rowIndex, int columnIndex):该函数主要是根据固定的值返回对应字段下的数据内容。
  6. getLogArray():返回当前类的List对象。

0x06 功能数据存放部分 LogTable

源码示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
package burp;

import javax.swing.JTable; // 用于显示和编辑单元格的常规二维表

/**
* 显示数据的类,该类继承了Burp的消息编辑器控制器
*/
public class LogTable extends JTable implements IMessageEditorController {

private LogTableModel logTableModel; // 存放数据的类
private IMessageEditor requestViewer; // 请求消息显示
private IMessageEditor responseViewer; // 返回信息显示
private IHttpRequestResponse currentlyDisplayedItem; // 获取或更新HTTP相关数据流

/**
* 构造函数,该类的只能接受该设置的参数
* @param logTableModel <code>LogTableModel</code> object
*/
LogTable(LogTableModel logTableModel) {

super(logTableModel); // JTable(TableModel dm) 获取需要显示的数据模型
this.logTableModel = logTableModel;
this.requestViewer = BurpExtender.callbacks.createMessageEditor(this, false);
this.responseViewer = BurpExtender.callbacks.createMessageEditor(this, false);
setAutoResizeMode(JTable.AUTO_RESIZE_ALL_COLUMNS);
// 当表被调整大小时,设置表的自动调整大小模式;在所有调整大小操作期间,按比例调整所有列的大小
getColumnModel().getColumn(0).setMinWidth(200);
getColumnModel().getColumn(1).setMinWidth(100);
getColumnModel().getColumn(2).setPreferredWidth(1000);
getColumnModel().getColumn(3).setMinWidth(100);
getColumnModel().getColumn(4).setMinWidth(150);
getColumnModel().getColumn(5).setMinWidth(100);
setAutoCreateRowSorter(true);
// 当调用setAutoCreateRowSorter(true)时,将TableRowSorter创建一个TableRowSorter并安装在表上。
}

@Override
public byte[] getRequest() {

return currentlyDisplayedItem.getRequest();
}

@Override
public byte[] getResponse() {

return currentlyDisplayedItem.getResponse();
}

@Override
public IHttpService getHttpService() {

return currentlyDisplayedItem.getHttpService();
}

@Override
public void changeSelection(int row, int col, boolean toggle, boolean extend) {
// 对选择的UI项进行调整大小长宽

Log logEntry = logTableModel.getLogArray().get(convertRowIndexToModel(row));
// convertRowIndexToModel(row)将视图的行的索引映射到底层 TableModel 。

requestViewer.setMessage(logEntry.requestResponse.getRequest(), true);
responseViewer.setMessage(logEntry.requestResponse.getResponse(), false);
currentlyDisplayedItem = logEntry.requestResponse;

super.changeSelection(row, col, toggle, extend);
}

/**
* 返回请求的HTTP的视图对象
* @return <code>IMessageEditor</code> object
*/
IMessageEditor getRequestViewer() {

return requestViewer;
}

/**
*返回相应的HTTP的视图对象
* @return <code>IMessageEditor</code> object
*/
IMessageEditor getResponseViewer() {

return responseViewer;
}

}

整体类函数功能:

该类函数主要功能可以分为两个部分,一个部分为自适应调整UI界面的字段位置,另一个就是展示HTTP数据的请求和相应的具体数据,而该部分的作用主要是便于在UI界面代码部分进行直接调用显示。

0x07 UI界面代码部分 MainPanel

源码示例:

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
169
170
171
172
package burp;

import java.awt.Color; // 颜色设置库
import java.awt.Component; // 组件功能库
import java.awt.FlowLayout; // 流程布局库
import javax.swing.BorderFactory; // 界面边界库
import javax.swing.BoxLayout; // 布局管理器库
import javax.swing.JButton; // 按钮库
import javax.swing.JCheckBox; // 复选框
import javax.swing.JComboBox; // 组合按钮或可编辑字段和下拉列表的组件库
import javax.swing.JLabel; // 文本显示区域库
import javax.swing.JPanel; // 面板库
import javax.swing.JScrollPane; // 提供轻量级组件的可滚动视图库
import javax.swing.JSplitPane; // 界面分列库
import javax.swing.JTabbedPane; // 通过点击具有给定标题和/或图标的选项卡,用户可以在一组组件之间切换的组件库
import javax.swing.JTextField; // 编辑单行文本库

/**
* 主显示界面类
*/
public class MainPanel extends JPanel implements ITab {
/**
* 继承了面板库,同时继承了Burp接口,该接口可以设置UI界面在Burp的UI上显示
*/

private LogTableModel logTableModel; // 存放日志表的对象类 { 可以用来查找相应数据的值}
private JTextField urlFilterText; // 当前手动文本输入参数
private JCheckBox scopeCheckBox; // 选择栏参数

/**
* 构造函数,输入参数必须符合该构造函数要求
* @param extender <code>BurpExtender</code> object
*/
MainPanel(BurpExtender extender) {

setLayout(new BoxLayout(this, BoxLayout.Y_AXIS));
// 指定组件应该从上到下布置(设置布局管理)

JSplitPane splitPane = new JSplitPane(JSplitPane.VERTICAL_SPLIT);
// 垂直分割表示Component沿y轴分割。 例如,两个Component将被分割成一个在另一个之上。

logTableModel = new LogTableModel(); // 日志表存放类
LogTable logTable = new LogTable(logTableModel); // 显示数据的类
JScrollPane scrollPane = new JScrollPane(logTable); // 日志数据的显示选择视图组件
splitPane.setLeftComponent(scrollPane); // 将组件设置在左边(或更高)分隔线

JTabbedPane tabs = new JTabbedPane();
// 带有请求/响应查看器的选项卡

tabs.setBorder(BorderFactory.createLineBorder(Color.black));
tabs.addTab("Request", logTable.getRequestViewer().getComponent());
tabs.addTab("Response", logTable.getResponseViewer().getComponent());
splitPane.setRightComponent(tabs);

JPanel controlPanel = new JPanel(new FlowLayout(FlowLayout.LEFT)); // 顶部控制面板

JLabel toolLabel = new JLabel("Select tool: ");
controlPanel.add(toolLabel);

String[] tools = {"All", "Proxy", "Intruder", "Scanner", "Repeater"};
JComboBox<String> toolList = new JComboBox<>(tools);
toolList.addActionListener(e -> {
String tool = (String) ((JComboBox) e.getSource()).getSelectedItem();
if (tool != null) {
switch (tool) {
case "All":
extender.setToolFilter(0);
break;
case "Proxy":
extender.setToolFilter(IBurpExtenderCallbacks.TOOL_PROXY);
break;
case "Intruder":
extender.setToolFilter(IBurpExtenderCallbacks.TOOL_INTRUDER);
break;
case "Scanner":
extender.setToolFilter(IBurpExtenderCallbacks.TOOL_SCANNER);
break;
case "Repeater":
extender.setToolFilter(IBurpExtenderCallbacks.TOOL_REPEATER);
break;
default:
throw new RuntimeException("Unknown tool: " + tool);
}
}
});
controlPanel.add(toolList);

JButton startButton = new JButton("Start");
controlPanel.add(startButton);
JButton stopButton = new JButton("Stop");
controlPanel.add(stopButton);
JButton clearButton = new JButton("Clear");
stopButton.setEnabled(false);
controlPanel.add(clearButton);
JLabel scopeLabel = new JLabel("In-scope items only?");
controlPanel.add(scopeLabel);
scopeCheckBox = new JCheckBox();
controlPanel.add(scopeCheckBox);
JLabel filterLabel = new JLabel("Filter URL:");
controlPanel.add(filterLabel);
urlFilterText = new JTextField(40);
controlPanel.add(urlFilterText);

startButton.addActionListener(e -> {
extender.setRunning(true);
startButton.setEnabled(false);
stopButton.setEnabled(true);
});

stopButton.setEnabled(false);
stopButton.addActionListener(e -> {
extender.setRunning(false);
startButton.setEnabled(true);
stopButton.setEnabled(false);
});

clearButton.addActionListener(e -> {
extender.getReqResMap().clear();
logTableModel.getLogArray().clear();
logTableModel.fireTableDataChanged();
});

controlPanel.setAlignmentX(0);
add(controlPanel);
add(splitPane);

// customize our UI components
BurpExtender.callbacks.customizeUiComponent(this);
}

@Override
public String getTabCaption() {

return "Request Timer";
}

@Override
public Component getUiComponent() {

return this;
}

/**
* Returns the Log table model
*
* @return <code>LogTableModel</code> object
*/
LogTableModel getLogTableModel() {

return logTableModel;
}

/**
* Returns the text to filter the URL by
*
* @return filter text
*/
String getURLFilterText() {

return urlFilterText.getText();
}

/**
* Is the in scope only checkbox selected?
*
* @return true if selected, false if not
*/
boolean isScopeSelected() {

return scopeCheckBox.isSelected();
}
}

整体类函数功能:

该部分代码不解读,我感觉应该是使用了类似WindowBuilder的工具进行设置的UI界面,部分代码意思可以详见以上代码注释部分。

0x08 插件主体逻辑部分 BurpExtender

源码示例:

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

import java.io.PrintWriter; // burp提示信息库
import java.net.URL; // 处理URL连接库
import java.time.LocalDateTime; // 获取指定时间库
import java.util.HashMap; // 字典形式数据储存库

/**
* Main class
* 插件主类,运行入口
*/
public class BurpExtender implements IBurpExtender, IHttpListener {

static IBurpExtenderCallbacks callbacks; // 回调参数
private IExtensionHelpers helpers; // 辅助参数
private MainPanel panel; // 插件面板类
private HashMap<URL, Long> reqResMap = new HashMap<>(); // 提前指定存在数据的字典形式参数
private boolean isRunning = false; // 判断是否运行中参数
private int toolFilter = 0; // burp自带的参数


/**
* Burp插件主回调函数,也是继承IBurpExtendre接口必须实现的函数
*/
@Override
public void registerExtenderCallbacks(final IBurpExtenderCallbacks callbacks) {

BurpExtender.callbacks = callbacks; // 把接口的参数传递给类本身参数
helpers = callbacks.getHelpers(); // 获取扩展辅助对象并传递给提前设置的参数
callbacks.setExtensionName("Request Timer"); // 设置插架显示名称
panel = new MainPanel(this); // 实例化同Burp包下面的界面显示类
callbacks.addSuiteTab(panel); // 添置插件的UI界面到Burp上
callbacks.registerHttpListener(this); // 注册监听模块
}

/**
* 设置运行状态
* @param running true or false
*/
void setRunning(boolean running) {

this.isRunning = running;
}

/**
* 设置数据来源
* @param toolFilter int representing the tool
*/
void setToolFilter(int toolFilter) {

this.toolFilter = toolFilter;
}

/**
* Process the HTTP message
*
* @param toolFlag originating tool
* @param messageIsRequest true if request, false if response
* @param messageInfo <code>IHttpRequestResponse</code> object
*/
@Override
public void processHttpMessage(int toolFlag, boolean messageIsRequest, IHttpRequestResponse messageInfo) {

if (isRunning) {
if (toolFilter == 0 || toolFilter == toolFlag) {
URL url = helpers.analyzeRequest(messageInfo).getUrl(); // 分析HTTP请求,获得URL
if (messageIsRequest) { // 判断当前是否调用或请求该方法
reqResMap.put(url, System.currentTimeMillis()); // 保存当前url和对应的发现时间
}
else {
if (reqResMap.containsKey(url)) { // 判断当前是否已储存该URL对应关系
long time = System.currentTimeMillis() - reqResMap.get(url); //计算获取响应的时间
reqResMap.remove(url); // 删除原指定关系
synchronized (panel.getLogTableModel().getLogArray()) { // 获取当前所有存放的URL信息数据;加上线程锁
int row = panel.getLogTableModel().getLogArray().size(); // 获取字典信息总的个数
if (panel.getURLFilterText().isEmpty() && !panel.isScopeSelected()) {
addLog(messageInfo, toolFlag, time, row); // 如果相关信息没有被记录,则进行记录信息
}
// 判断是否开启日志筛选器
else if (!panel.isScopeSelected() && !panel.getURLFilterText().isEmpty() &&
helpers.analyzeRequest(messageInfo).getUrl().toExternalForm().contains(panel.getURLFilterText())) {
addLog(messageInfo, toolFlag, time, row);
}
// Log in-scope requests
else if (panel.isScopeSelected() && panel.getURLFilterText().isEmpty() &&
callbacks.isInScope(helpers.analyzeRequest(messageInfo).getUrl())) {
addLog(messageInfo, toolFlag, time, row);
}
// Log in-scope requests and filter
else if (panel.isScopeSelected() && !panel.getURLFilterText().isEmpty() &&
callbacks.isInScope(helpers.analyzeRequest(messageInfo).getUrl()) &&
helpers.analyzeRequest(messageInfo).getUrl().toExternalForm().contains(panel.getURLFilterText())) {
addLog(messageInfo, toolFlag, time, row);
}
}
}
}
}
}
}

/**
* Helper to add a log entry
*
* @param messageInfo <code>IHttpRequestResponse</code> object
* @param toolFlag tool
* @param time time taken in ms
* @param row row to insert at
*/
private void addLog(IHttpRequestResponse messageInfo, int toolFlag, long time, int row) {

panel.getLogTableModel().getLogArray().add(new Log(LocalDateTime.now(),
callbacks.getToolName(toolFlag),
callbacks.saveBuffersToTempFiles(messageInfo),
helpers.analyzeRequest(messageInfo).getUrl(),
helpers.analyzeResponse(messageInfo.getResponse()).getStatusCode(),
helpers.analyzeResponse(messageInfo.getResponse()).getStatedMimeType(),
time));
panel.getLogTableModel().fireTableRowsInserted(row, row);
}

/**
* Get the request map
*
* @return the request map
*/
HashMap<URL, Long> getReqResMap() {

return reqResMap;
}
}

整体类函数功能:

整体界面主要函数,和上一个入门篇文章中记录的编写思路是一样的,而 processHttpMessage() 的功能,主要是计算请求的URL所花费的时间,和把请求的数据进行一个储存,并且保持不会出现重复记录的情况。

0x09 总结

总结起来Burp插件的思路,其实就是对API接口的调用,然后进行一系列数据规则的处理,通过上面源码分析可以更便于我们自行进行造轮子和在别人轮子的基础上进行优化调整。

如果对于某些轮子中的函数和参数不太理解的话, 可以只用用文章一开始debug的方式,进行更加深入的了解某些参数和函数的作用,还有整个插件程序的执行顺序。


BurpSuite插件编写[初级篇]
https://sh1yan.top/2020/04/12/Writing-of-burpseuite-plug-in-preliminary/
作者
shiyan
发布于
2020年4月12日
许可协议