Reverse analysis of encryption and decryption functions of a certain color software [First taste of go language reverse]

foreword

I saw a color software by chance while surfing the Internet, which aroused my interest in analysis. In the process of reverse analysis, I found that the native layer was written in go language. I hadn’t touched the reverse of go language before, so I also took this The opportunity to learn the relevant knowledge of go language reverse. I recorded the main process of analyzing this software, and I will share it with you here.

java layer analysis

Directly use httpcanary to capture packets. Among many packages, I choose the package with userinfo in the url. This is the way to obtain user data.

insert image description here

Looking at its request body and response body, I found that the value with the key name "data" is encrypted data, so my goal is to find the location where the data data is encrypted and decrypted, and restore their algorithm.

请求体:
{"data":"5156526f6a634c4a744765506f684c746be324ec3592da9430b9ac8d07dab8c9183d56117c55bbb9001ded438ac2cd7776c25e4c57902ade91cbcd254747f54d2e0b91c47c54116ce1606ac59e355334d30cbf1f2794a64ef137d9593fbf03951044b189b716e7315af62e248195436b0a00ea7036626667eb03abe7ad73c417d3c6add6c2eaa502ea30f84fdb07cd70df8f10813bfcdab98d216704ba012ec30bbaffa3cdf2595e0cd8ce2d1f0c09d8d5d3803bcc8c2ac4c4ca0786b18bc96eb457f602b0724207ecca5ea0ff05e2e53ed777a1eb4805be967a490a30822b2942c3afb242865687ad3d1d07acc7ad90e75110f2f089747a541ba482f5ae6cb5c6f8a2ad2d2ceaab610c5b086364c9deec9b4c38f83f47c74f1644f5bc73204f6f048722b9ac976ed218cce50545fce2c5ceb9716970dd26f13efe9f83050bc7bb2a09e72360be358f5b17bf646b589cb3dc8e8f04806c9ffb9d9ca244141b73f2bca84dc361d12cf8fe197009592018bf174e86aa68b3f0eec6c823a217498d8a2bd57c495b2dc798f99caa601d69259fcd0004e8da0dd854d59daed89238fd2bc1ac2b2c69d8d14a639555c3497947641bb8076c69c8555eed606aac6eb742a2ca061c130bb4b1d034bb9a0b6afebe13e39cbe010343cd49971c76f5b83a6e1b69f45726a148c541da76eae511df3712dfbe26b119f3fd9fe104ada39e346c1025c110272863f5d55a3ab07accd38ed71f2fc46857d26177ebc0f2b7b82aa926c0c2a20efd9b0060a24b683e9beca8030561cc879b0d5a95fb6beec131b5e48f7b52fd6087fe8f2d927c2adad87f9aa2d50c857e6f3004729f7dfadc583918e7e4830f181bb185575254683e47bb3ab6bb829d5db789875160a2b6cce8e7f256e4ea08a80782dde53beaebf5f7720620a667649d3e44af","_ver":"v1","sign":"220676c5b98327c02d44c0b2252fd11a","timestamp":"1674027054"}
响应体:
{"errcode":0,"timestamp":1674026760,"data":"33F508647B1E3CFBBD09D0DEEAA5249F34BA0787663F026C278020170183093CEB9305656502A9E2D38054FA2C86773FD535EC53775A23713C7720BA47A7839E13AC9271E03C8E521F47E5EDF7BC0D8E6303761737FADE6BDF3D4DBBA2B618B2F7C311389CC6F371E41CC006D465206DB27B92841DD3D9D772CEE6A2345E8B58D224DA72B54597FE8223F189553F2E64A3F13384479DB64104786E6C0143ABB02632EEFB370787253DFC1FB8DA2A77F36AE3A1B1354D288C327D8E1D9622E2088C286B162AF045215B1630E58B9A0459D6EB351B713A35CD7AEF4A71956E4C6BFE19BCA9581B773936B412FAF0EFB204C052328C864C044AB49A716AAE33CDC127FA92A2B2475A351B4071C06D078AFF399C4195F99FC68CE222420CA2336D0756C7DD5F9E28C790BA884AFF28499A2A42DDDC92B7420BB17B7058CEB71B1A6810241FD54897F7B74954B027BED49AA6E332B633D13B0D30CF050D48F16FCDCAE92C7B0D6EA6B0427B1942D94D98B7C9C5E03E0875B8F4C6EFDA1338049ABA59F30FC1323B947A66B20DDF6546AFA8A4B4F1925E6891045F825E0AF32A27B6BDD56B52B7BBEDD070F5FFD8BC35F9473FB3246957B7CCBD4F823C90D6CB7B3A664393D4FE97D6323F07150A83CD31519C48D017E08E35E690C12A292461FD6E87E71249964A47F10EA45FAD61AE047CE860DC11E3AC86195FB0BE45762E4650EE0CA70ED1F4EABAAC56D26E0AB3C5C73561D95744E795121736B35A4CFAE4A62B20AC2A490373578719B3A3F2B8586D1CEA8A0924722721960F849F4909FCE0C9A6C684B9E2EE8AB0ABB99F756EC09D30E0F0A29897E5351E7CF10612A82954B662AFB10A95E14BCEEB7140CC805BBBF0C56BEFF5A609C4E80C379990956D6D4E9E8137F9CA6DA9E1D5BA6B88230AC0F4B5CDAD88E02F8CB45EBE23BEC53BF570C5B1F2D434256326ABCEF1C22FA5F738473545A8D924C01F96CF12775DD1F2715FE56E80737CE6F8771278CDE83C86DEF56B9C8736FEC2DC07308A4BE5F9019C433E83B4A3EBA69E2C5D993CAFD6C8B203F3BDE7CCD8EDCEA743865080FA061841AFABEAC7D543B3D4EBDBB2E7549DD1456C995D98ED7CB50303F70E48F850763D9F895969FB25EAF0A889696C4D73009E54800F28D5904B5593CF87210CC647A2DCB1113AEA0F73DCFDADC28BF7AD4FD6FCCE0D4A3F3B4242CCB29726A3781FF2D6C5D755E63F38E2F6424E7D69C67EA979322F7C9BB4A521EF566C46D31370BDBD8B26A95D82B24D0B340DA7A643647B7870A1C3507AB4FA459B225055A184BBEF0564CB052B02CC6C41D08EE03F907EC82A2A6C90F2437C5E1F7551CFD1D31E18AE065005C808E1991C0A9D3F6E86598C1333D9C4AE110060233DFE97622F8354DAAB0185313DFE15D3CCC99F89131B8DAC3DD0416C47693BBB55A5D707CCBC9B61E9594FCC4A0F626A828A55F0A3CB67ACCF30613D90342060FD7869A2EFB7149300D246716F1AB34D9DAA8F415657B754EFB80240646DE4C5264601BF959611252F4C1AAD1AFDD86FBB52D37C1A9DAF343FC9A791314BB18E3FE6B81B7555621EEB2A8CA408BF86D6261A2CFF6E5BB8873275923020ABEE93D56D3A4D195BBC4E40E652EFDA56D757A41ACA13BE26EF170674A9A6B113163B754C52592B691A969699D785B63E7A482A74FDC401CBA0FFD2AA7E81B47489CFE4D68B3AE7723A97F5EF421CD8FE9D64E2BE2F2A73F93828503D3175A4708E55961F6393826E4B56EDEF13AE1668066B90717AFDD33BF5385A34A6B847A717346874F2717A0F9DE5DF01D66C39E38194A3A441DAB4476881ED9B3206E3D5C8DFEF9EDA3793E53368819E09BF4F7A5E494EEFABED0EAEDDF5674B5182D327296B96D3195B152A1940D29DA9B8A81963EFE1031C20BCFFF6C9CEB1FD8CF04E8CA6DA45AA15FF8086C243A6782093","sign":"e32f75381d198788d65ecd7d23559f2f"}

To find the splicing position of the data value, I have the following ideas:

  1. Search for the "data" string directly in jadx, and verify it with frida to locate the splicing position of data
  2. Use r0capture to locate the location of the software sending and receiving packets, and then find the location of parameter splicing by backtracking
  3. By hooking some commonly used parameter splicing functions, combined with the call stack method to locate the position of parameter splicing

For this software, the above three ideas can all get the splicing position of the data value. I will only talk about the third method here, and readers can try the other two methods by themselves.

I mainly hook three commonly used classes, and filter the results of the hook through "data", the script code is as follows:

function hook_tools() {
    
    
    let JSONObject = Java.use("com.alibaba.fastjson.JSONObject");
    JSONObject["put"].overload('java.lang.String', 'java.lang.Object').implementation = function () {
    
    
        if (arguments[0] == "data") {
    
    
            var value = arguments[1]
            value = Java.cast(value, Java.use("java.lang.String"))
            console.log("JSONObject put data --> " + value)
            console.log(Java.use("android.util.Log").getStackTraceString(Java.use("java.lang.Throwable").$new()));
        }
        let ret = this.put(arguments[0], arguments[1]);
        return ret;
    };

    let HashMap = Java.use("java.util.HashMap")
    HashMap["put"].overload('java.lang.Object', 'java.lang.Object').implementation = function () {
    
    
        if (arguments[0] == "data") {
    
    
            var value = arguments[1]
            value = Java.cast(value, Java.use("java.lang.String"))
            console.log("HashMap put data --> " + value)
            console.log(Java.use("android.util.Log").getStackTraceString(Java.use("java.lang.Throwable").$new()));
        }
        let ret = this.put(arguments[0], arguments[1]);
        return ret;
    }

    let org_JSONObject = Java.use("org.json.JSONObject")
    org_JSONObject["put"].overload('java.lang.String', 'java.lang.Object').implementation = function () {
    
    
        if (arguments[0] == "data") {
    
    
            var value = arguments[1]
            value = Java.cast(value, Java.use("java.lang.String"))
            console.log("org_JSONObject put data --> " + value)
            console.log(Java.use("android.util.Log").getStackTraceString(Java.use("java.lang.Throwable").$new()));
        }
        let ret = this.put(arguments[0], arguments[1]);
        return ret;
    };
}

function hook_java(){
    
    
    Java.perform(function(){
    
    
        hook_tools()
    })
}

function main(){
    
    
    hook_java()
}

setImmediate(main)

After the software injects the frida script, the running results are as follows:

insert image description here

By comparing with the result of packet capture, it is confirmed that the result of frida hook is correct, and I combine the call stack to start com.tencent.mm.network.d.q2the analysis from the function

insert image description here

According to the call stack, the q2 function will call the JSON.parseObject function, so the red box in the above figure is the key point. The parameters of the parseObject function should contain data. Click on the o2 function to observe

insert image description here

This function generates a string as a return value through the k function, so enter the k function to have a look

insert image description here

In the k function, you can see the assignment position of data, which is the return value of e(encryptData). I use the frida hook e function to verify it.

function observe_program(){
    
    
    let c = Java.use("com.szcx.lib.encrypt.c");
    c["e"].implementation = function (plainText) {
    
    
        console.log('e is called' + ', ' + 'plainText: ' + plainText);
        let ret = this.e(plainText);
        console.log('e ret value is ' + ret);
        return ret;
    };
}

function hook_java(){
    
    
    Java.perform(function(){
    
    
        observe_program()
    })
}

function main(){
    
    
    hook_java()
}

setImmediate(main)

insert image description here

After verification, the e function is the location where the data value is generated. At the same time, the parameter passed in by this function is plain text, and the return value is cipher text. I enter the e function to observe

insert image description here

insert image description here

insert image description here

The e function finally calls the encrypt function of the native layer. Its first parameter is the plaintext to be encrypted, and the second parameter should be the key. At the same time, it can be found that there are many decrypt functions in the class EncryptUtil to which the encrypt function belongs. From the name As you can see, these should be the decryption functions. So it can be guessed that the EncryptUtil class is the core encryption and decryption tool class of this software, so as long as these functions are hooked, the packet capture of this software can be realized.

When writing frida scripts, if you hook these native functions at the same time, the software will get stuck, but if you hook their callers, you can solve this problem. I only hooked the two functions of encrypt and decrypt, and other functions should be the same as The picture is related to the video, I don't pay attention to it, the packet capture script is as follows:

function capture(){
    
    
    let c = Java.use("com.szcx.lib.encrypt.c");
    c["f"].implementation = function (plainText, encryptKey) {
    
    
        console.log('encrypt is called' + ', ' + 'plainText: ' + plainText + ', ' + 'encryptKey: ' + encryptKey);
        let ret = this.f(plainText, encryptKey);
        console.log('encrypt ret value is ' + ret);
        console.log("===========================================")
        console.log("===========================================")
        return ret;
    };

    c["b"].implementation = function (encrypted, encryptKey) {
    
    
        console.log('decrypt is called' + ', ' + 'encrypted: ' + encrypted + ', ' + 'encryptKey: ' + encryptKey);
        let ret = this.b(encrypted, encryptKey);
        console.log('decrypt ret value is ' + ret);
        console.log("===========================================")
        console.log("===========================================")
        return ret;
    };
}

function hook_java(){
    
    
    Java.perform(function(){
    
    
        capture()
    })
}

The packet capture effect is also very intuitive. The figure below shows the decrypted data of the data value in the response body when requesting userinfo

insert image description here

Circled in the red box is the user's membership information, as long as you change false to true, you can get membership privileges, readers please try it yourself.

After finding the encryption and decryption functions of the java layer, it is not finished. The two native functions, encrypt and decrypt, will be analyzed below, and their algorithms will be restored and executed offline.

Native layer reverse analysis

Before analyzing the encrypt function, first write an active call function, pass in the plain text "123456", the key is "BwcnBzRjN2U/MmZhYjRmND4xPjI+NWQwZWU0YmI2MWQ3YjAzKw8cEywsIS4BIg=" obtained by the hook, after multiple calls, it is found that under the same input conditions, the encrypted result is changing. The active call script is as follows:

function call(src) {
    
    
    Java.perform(function () {
    
    
        let EncryptUtil = Java.use("com.qq.lib.EncryptUtil");
        let pwd = 'BwcnBzRjN2U/MmZhYjRmND4xPjI+NWQwZWU0YmI2MWQ3YjAzKw8cEywsIS4BIg=='
        let ret = EncryptUtil.encrypt(src, pwd);
        console.log("encrypt ret --> " + ret)
    })
}

insert image description here

Use ida to parse the libsojm.so file, search for encrypt in the export table, you can judge that it is a statically registered function

insert image description here

Look at the pseudocode of the Java_com_qq_lib_EncryptUtil_encrypt function, the decompilation effect is very bad, and the parameters are not recognized

insert image description here

This is caused by the different function calling conventions of the go language. The ATPCS function calling convention is used on the arm platform. Parameters 1~4 are stored in registers R0~R3 respectively, and the remaining parameters are pushed onto the stack from right to left and are The caller implements stack balance, and the return value is stored in R0. The go language uses the stack to pass parameters, and the return value is stored under the last parameter. Take a look at the assembly instructions

insert image description here

The ATPCS function calling convention is followed in the Java_com_qq_lib_EncryptUtil_encrypt function. The called crosscall2 function will restore the environment required by the Golang runtime. The first parameter is a pointer to the function interface, the second parameter is the parameter address, and the third parameter is the size of the parameter. The function pointer points to _cgoexp_17c794619cba_Java_com_qq_lib_EncryptUtil_encrypt, I set a breakpoint here to start debugging

insert image description here

insert image description here

insert image description here

insert image description here

insert image description here

What should be noted above is that the string in the go language does not use \0 as the terminator. It is a structure. The upper part of the memory is the first address of the string, and the lower part is the length of the string. Its definition is as follows:

type StringHeader struct {
    
    
    Data uintptr            // 字符串首地址
    Len  int                // 字符串长度
}

Next, enter the encryption function analyzed in the previous step to continue debugging

insert image description here

The function called at C3E3FD80 passed in pwd and returned two strings, one is a string "mIZUjjghGd" with a length of 0xA, and the other is a string "4c7e?2fab4f4>1>2>5d0ee4bb61d7b03" with a length of 0x20 , it should be noted here that the two strings are continuous in memory, but they are not a string, because the string in go language does not use \0 as the terminator, but has a specified length

insert image description here

Continue to step down to debug and find that a function passes in the input and the two strings obtained above, and returns the encrypted ciphertext after the call

insert image description here

Enter this function to continue the analysis. In this function, there is first a for loop to do a wave of calculations

insert image description here

After debugging, it is found that the loop operates on the string "4c7e?2fab4f4>1>2>5d0ee4bb61d7b03", and the new string "3d0b85afe3a3969592c7bb3ee16c0e74" with a length of 0x20 is obtained after the operation

insert image description here

continue to debug

insert image description here

The function in the figure above passes in two strings, and returns a new string "fc9e9a75e633ee4d317b08520b6c3ba9" after processing, continue to debug

insert image description here

This function only passes in a parameter 0x10, and returns a string with a length of 0x10. After many times of debugging, it is found that this string is randomly generated, which is why the encrypted results are different under the same input conditions. reason

continue to debug

insert image description here

This function passes in the previously generated string "fc9e9a75e633ee4d317b08520b6c3ba9", and returns a byte array with a length of 0x20. As I said in advance, this is the key of the encryption algorithm. After multiple debugging, it is confirmed that this will not change, so The result can be used directly.

insert image description here

The function that the BLX R0 instruction jumps to is an encrypted function. The plaintext "123456" is passed in, and the encrypted result is returned. After entering it, I found that the logic is quite complicated. I used the Findcrypt plug-in to try to find this so Which encryption algorithms are used in the following figure is the result of Findcrypt operation

insert image description here

Seeing that it has the characteristics of the AES encryption algorithm, how to verify it here? The sub_CC7BC function is a user of several AES arrays, so you can set breakpoints on the above BLX R0 and sub_CC7BC functions. If the breakpoint at the sub_CC7BC function is triggered after the breakpoint at BLX R0 is triggered, then this can be determined. The encryption algorithm is most likely the AES encryption algorithm. After debugging, it is verified that the above assumptions are true, so the following is to determine which mode of the AES encryption algorithm is used.

In the previous debugging process, I got a byte array with a length of 0x20 and a random string. The byte array may be the key, and the random string may be the iv. Try it in cyberchef.

insert image description here

After trying, it can be determined that this encryption algorithm is the standard AES-256-CFB.

After the plaintext is encrypted by AES, the BLX R0 continues to debug

insert image description here

The function of this function is to splice the AES encrypted result with a randomly generated string.

Then debug down, and encountered a loop operation, the pseudo code is as follows:

insert image description here

What this loop operation does is also very simple, which is to convert the hexadecimal form of the strings spliced ​​together into a string.

The string obtained through the above cyclic operation is the final result, and all the encryption processes have been analyzed so far. To sum up, first pass the incoming pwd through a series of operations to obtain a byte array with a length of 0x20, and then generate a random string with a length of 0x10, use the byte array as the key, and the random string as iv to do the plaintext The encryption operation of AES-256-CFB, and then the random string and the AES encrypted result are spliced ​​together, and finally their hexadecimal is converted into a string to complete the entire encryption process.

Similarly, the decryption process should be the reverse operation of the encryption process. First, we can take each byte of the ciphertext as a hexadecimal, every two hexadecimals are a byte, and the first 0x10 bytes are iv , and we know the key, we only need to perform the AES decryption operation on the ciphertext to get the plaintext.

Execute offline

I use python to restore the encrypt and decrypt algorithms, and use the requests library to send network requests, the complete code is given below

import json
import random
from Cryptodome.Cipher import AES
import time
import hashlib
import requests

def generate_random_str(len):
    table = b"abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ"
    arr = []
    for i in range(0x10):
        index = random.randint(0, 51)
        arr.append(table[index])
    return str(bytes(arr), encoding = "utf-8")

def aes_encrypt(text, iv):
    key = [0xA7, 0x91, 0x4B, 0x68, 0x21, 0x10, 0x16, 0x5D, 0xDB, 0x0B, 0xA7, 0xCF, 0x4E, 0xAC, 0xBE, 0xFC, 0x1C, 0xC2, 0x1F, 0x56, 0xF3, 0x41, 0xB8, 0x2A, 0x89, 0x0A, 0xCA, 0xED, 0x2C, 0xC3, 0x4A, 0x75]
    key = bytes(key)
    mode = AES.MODE_CFB
    cryptos = AES.new(key, mode, iv.encode(),segment_size=128)
    cipher_text = cryptos.encrypt(text.encode())
    return list(cipher_text)

def custom_encrypt(text,random_str,aes_encrypt_list):
    abc_table = b"0123456789abcdef"
    v20 = 0
    v21 = 0
    v31 = 0x10 + len(text)
    arr = []
    for i in range(v31 * 2):
        arr.append(0)
    table = random_str.encode('utf-8') + bytes(aes_encrypt_list)
    while (v20 < v31):
        v23 = table[v20]
        arr[v21] = abc_table[v23 >> 4]
        arr[v21 + 1] = abc_table[v23 & 0xF]
        v20 = v20 + 1
        v21 = v21 + 2
    return str(bytes(arr), encoding="utf-8")

def decrypt(text):
    arr = []
    arr_reHex = []
    text_bytes = text.encode()
    for i in text_bytes:
        arr.append(int(str(chr(i)), 16))
    for i in range(0, len(arr) // 2):
        arr_reHex.append((arr[i * 2] << 4) | arr[i * 2 + 1])
    arr_iv = arr_reHex[0:0x10]
    arr_cipher = arr_reHex[0x10:]
    key = [0xA7, 0x91, 0x4B, 0x68, 0x21, 0x10, 0x16, 0x5D, 0xDB, 0x0B, 0xA7, 0xCF, 0x4E, 0xAC, 0xBE, 0xFC, 0x1C, 0xC2, 0x1F, 0x56, 0xF3, 0x41, 0xB8, 0x2A, 0x89, 0x0A, 0xCA, 0xED, 0x2C, 0xC3, 0x4A, 0x75]
    key = bytes(key)
    mode = AES.MODE_CFB
    cryptos = AES.new(key, mode, bytes(arr_iv), segment_size=128)
    plain_text = cryptos.decrypt(bytes(arr_cipher))
    return str(plain_text, encoding="utf-8")

def generate_sign(data):
    appKey = "81d7beac44a86f4337f534ec93328370"
    timestamp = str(int(time.time()))
    text = "_ver=v1&data=" + data + "&timestamp=" + timestamp + appKey
    text_sha = hashlib.sha256(text.encode('utf-8')).hexdigest()
    text_md5 = hashlib.md5(text_sha.encode('utf-8')).hexdigest()
    return text_md5

def get_userInfo(data, sign):
    headers = {
    
    
        'User-Agent': 'okhttp-okgo/jeasonlzy',
        'token': 'F39FED66EB36A4B94B308114826D0DBCAFDCAFA186F0938BD5CCF4117B28D586E6780DE54474D93BA0C8EC44B1FA1797F245AB0F648DFC62B60184916108DEDF2F0A490D5475059855ACEE4FCDCE2E91ED2BA128F68135896D8D8192DAC604BEDDE68FA864'
    }
    j = json.dumps({
    
    'data': data, '_ver': 'v1', "sign": sign, "timestamp": str(int(time.time()))})
    r = requests.post("http://api50.fiftymvapi.com:8080/api.php/api/user/userinfo",headers=headers , data=j)
    return r

if __name__ == '__main__':
    text = '{"system_build_id":"a1000","system_iid":"f7767b9f58a4ce56ba2b5ea559fbbdbc","app_status":"9001A7FD9DDFE91CDA376F4EB9DD0E2FC915ADA4:2","system_version":"5.7.0","system_build_aff":"","bundle_id":"jp.fihvv.vrdrrh","system_app_type":"local","new_player":"fx","system_oauth_id":"ee916e21e1eda4fa5bf5dc522e088f20","system_oauth_type":"android","system_token":"B8B8D7BF07295EF62A008193126FA3D0C34AFC437ED0E4DDE00E83D4F81647364884A6B1BCED057E77384252F5F7AFF92E243C7E6C266ED02D388212B291AC775F5F7857B9E51DF19A4E24E455AC2AA664141F3701D4F31A7F96B921C589BE83827EBB367C"}'
    random_str = generate_random_str(0x10)
    aes_encrypt_list = aes_encrypt(text, random_str)
    custom_encrypt_str = custom_encrypt(text,random_str,aes_encrypt_list)

    sign = generate_sign(custom_encrypt_str)

    r = get_userInfo(custom_encrypt_str, sign)

    if r.status_code == requests.codes.ok:
       print("请求userinfo成功")
       print("响应体:")
       print(r.text)
       print("data解密数据:")
       print(decrypt(r.json()["data"]))
    else:
        print("请求userinfo失败")

Running effect display:

insert image description here

appendix

Software link: https://pan.baidu.com/s/16DZ3ReL2kMwjI4tAFE39qw?pwd=7xve

Guess you like

Origin blog.csdn.net/weixin_56039202/article/details/128743295