【浅谈系列】Authenticated encryption

11 minute read

Published:

密码分析学课上最近讲了认证加密,恰巧以前比赛里遇到过多次相关题目,简单总结一下。

密码分析学课上最近讲了认证加密,恰巧以前比赛里遇到过多次相关题目,简单总结一下。

写完本篇博客的时候发现最近的 WMCTF 里面居然又出了这个知识点,而且貌似和红明谷那道题几乎一样

0. 参考资料

1. 认证加密基本概念

认证加密,即 Authenticated encryption,是一种除了像一般的加密能够保证完整性 Integrity 和机密性 Confidentiality 以外,还自带有身份认证的加密方式。

认证加密的一种变体是带有关联消息 associated data 的认证加密 AEAD

绝大部分认证加密方案是将加密和消息认证码 MAC 相结合。一般地,认证加密的加密和解密可以图示如下:

加密时,加密部分和消息认证码部分使用不同的密钥,生成密文与 Tag

解密时,解密后用得到的明文验证明文生成的 Tag 是否与原来的 Tag 一致。

由于多了身份认证的这一步,我们可以看出,相比一般的加密,认证加密可以有效地防御选择密文攻击,因为攻击者无法对选择好的密文生成合法的 Tag

而关联数据 associated data 的主要作用则是确定密文处在正确的上下文中。举个例子,对于某个登录系统的数据库管理员,如果不使用 associated data,那么一个恶意的管理员可以将自己在系统里的某些数据和某个用户的对应某些数据交换位置,而登录自己的账户,要求系统对自己的该条信息(此时已经换成另一位用户的了)进行解密。而如果使用关联数据,那么当他要求解密时,系统会敏锐地察觉到解密的上下文和原本应在的上下文发生了变化!于是系统会拒绝这次解密。具体细节详见 参考资料

2. 认证加密发展历史

认证加密发展历史的一个简单描述如下图所示:

其中,CAESAR 竞赛和 NIST 的轻量密码标准制定推动了近几年各类加密方案的发展。NIST 轻量密码的决赛圈在今年三月已经确定,决赛圈为期一年,花落谁家明年我们拭目以待。

具体的历史上出现了什么重要的方案,请参考下一部分给出的 PPT。

3. 认证加密如何设计

认证加密既然是认证和加密的组合,那从这个角度,我们可以想到三种设计方法:

  • Encrypt-and-MAC

    基于明文生成消息认证码,并对明文加密,此加密和消息认证码无关,而后消息认证码与密文一同传送。用于比如 SSH。可以保证明文的完整性,但无法保证密文的完整性,因此可能存在选择密文攻击。MAC 可能会显示明文有关信息。

  • MAC-then-encrypt

    基于明文生成消息认证码,然后将明文和消息认证码一起加密生成密文。用于比如 SSL/TLS。可以保证明文的完整性,但无法保证密文的完整性。MAC 是加密的,因此不会显示明文有关信息。

  • Encrypt-then-MAC

    明文加密得到密文,并基于密文生成消息认证码。用于比如 SSHv2。可以保证明文完整性与密文完整性,且 MAC 不会显示明文相关信息。

三种方案的安全性如下图:

关于认证加密具体如何设计,以及认证加密发展历史中相关经典方案,在百度文库中可以找到一篇总结的很细致的文档,是王鹏老师的课件,认证加密的设计 - 王鹏,应该是以前上过密码分析学这门课的学长上传的。

可见认证加密往往涉及密码学多方面知识的综合。值得一提的是,PPT 中提到的 Sponge 海绵函数,我曾在大二的 CTF 里遇到过,当时还没有这方面的储备,初次碰到非常地懵。该题详见:0CTF 2019 Quals Writeups - Baby Sponge

4. CAESAR 竞赛

CAESAR 竞赛是由 NIST 发起的密码算法征集竞赛,为了找一个综合性能优于 GCM 的认证加密算法。始于 2013 年,终于 2019 年。目前已有最终结果:CAESAR submissions Final portfolio

关于这个竞赛的更多细节,软件所的吴文玲研究员写过一篇综述,详见:认证加密算法研究进展 - 吴文玲。这篇综述里罗列了竞赛中出现的主要算法,给出了它们大概的设计理念、优缺点,以及部分方案为何被淘汰。

5. CTF 相关题目:对 OCB2 的攻击

认证加密相关方案中非常著名的一个是 OCB2。这个方案由知名密码学家 Rogaway 设计,并且他对这个方案的安全性给出了证明。在很长一段时间内,学术界都认为这个方案是不存在什么问题的。

在取得数学硕士学位后,Akiko Inoue 进入日本电气公司从事密码学研究,并接受了 Kazuhiko Minematsu 的指导。而在阅读 OCB2 论文的过程中,她发现了该方案存在的问题,并给出了相关攻击,因此获得了 Crypto 2019Best Paper Award

而近一年在国内 CTF 比赛中,对 OCB2 的攻击也多次出现作为赛题。我有印象的第一次出现是在红明谷杯,第二次是在津门杯,第三次出现就是本篇博客完成时的前不久,WMCTF

Akiko Inoue 的 18 年论文如下,我觉得这篇论文写的很好,由浅入深,通俗易懂,非常值得学习:

简述

阅读如上的论文,我们可以知道,作者指出 Rogaway 设计上存在的主要问题是将 XEXXE 混用,使得原本安全的两者,混用后失去了安全性。

作者首先给出了最简单的 Minimal Forgery,然后推广到 Forgery of Longer Messages,并给出了两个变体,最后提出了在前面基础之上比较实用的 Universal Forgery。在 2019 年更深入的研究的一篇论文 Cryptanalysis of OCB2: Attacks on Authenticity and Confidentiality 中,作者还补充了 Universal ForgeryPlaintext Recovery 部分,以及给出了对 OCB2 如何修正的建议。

本文只谈最简单的 Minimal ForgeryUniversal Forgery,其他更多建议阅读论文进一步了解。GitHub 上也已有人给出 2019 那篇论文的实现,参考 OCB2-POC - oalieno

Minimal Forgery

针对特殊的 $M’ = 2L \bigoplus len(0^{n})$,给出了一种构造合法的 $T$ 的方法。

且可以利用返回的值得到 $L$。

Universal Forgery

如 2018 论文所说,利用 Minimal ForgeryLonger Messages,我们可以恢复出整个 $E_k(?)$。

而通过设定 $N = 0^{n}$,利用本地跑的 $E_k(?)$,PMAC 只和关联数据相关,是可以计算的。

论文中描述的攻击步骤如下:

红明谷杯 2021babyForgery 为例,步骤如下:

关联数据只要在恢复出 $E_k$ 之后,进行一次 $N = 0^{n}$ 的 basic forgery 即可算出 PMAC,因此下面的推导不涉及关联数据,主要涉及如何恢复 $E_k$。

  • 第一步 Part I

    第一步的目的是得到 $L$,并为第二步做准备。

    使用在线的 encrypt,输入 $(N_0,\;M_0)$,为了 Minimal Forgery,不妨令

    \[M_0[1] = len(0^{n}),\; M_0[2] = len(0^{n})\]

    得到 $(C_0[1],\;C_0[2],\;T)$。

  • 第一步 Part II

    使用在线的 decrypt,输入

    \[(N = N_0,\;C = C_0[1] \bigoplus len(0^{n}),\;T^{\ast} = M_0[2] \bigoplus C_0[2],\;A = \epsilon)\]

    得到 $M_0’ = 2L_0 \bigoplus len(0^{n})$。

    于是可以获得 $L_0$,$L_0 = E_k(N_0)$,且 $T^{\ast}$ 确实可以顺利通过检验。

    且可以知道如下关系,记为 $\text{Z}$:

    \[E_k(2L_0 \bigoplus len(0^{n})) = C_0[1] \bigoplus 2L_0 = \text{Z}\]
  • 第二步

    第二步的目的是构造本地的 $E_k(?)$。

    不妨设你想要的某个明文为 $message$。

    令 $N_1 = 2L_0 \bigoplus len(0^{n})$。

    令 $M_1[i] = message[i] \bigoplus 2^i (C_0[1] \bigoplus 2L_0)$

    使用在线的 encrypt,输入 $(N_1,\;M_1)$,得到

    \[L_1 = E_k(N_1) = E_k(2L_0 \bigoplus len(0^{n})) = \text{Z}\]

    且有

    \[\begin{align} C_1[i] &= E_k(M_1(i) \bigoplus 2^iL_1) \bigoplus 2^iL_1 \\ &= E_k(message[i] \bigoplus 2^i \text{Z} \bigoplus 2^i \text{Z}) \bigoplus 2^i \text{Z} \\ &= E_k(message[i]) \bigoplus 2^i \text{Z} \end{align}\]

    于是可以知道对于 $message$,有 $E_k(message[i]) = 2^i \text{Z} \bigoplus C_1[i]$。

    因此我们利用第二步便可以对于任意消息构造一个本地的 $E_k(?)$ 对其加密。

  • 第三步

    第三步是如何利用已有的东西,构造我们想要的对于任意 $(N,\;M)$ 的 $(C,\;T)$。

    为了方便,不妨假设 $N_3 = N_0$,于是由第一步可以得到 $L_3 = L_0$。

    于是要求有

    \[M_3[i] = D_k(C_3[i] \bigoplus 2^i L_0) \bigoplus 2^i L_0 = message[i]\]

    因此需要

    \[\begin{align} C_3[i] &= E_k(message[i] \bigoplus 2^i L_0) \bigoplus 2^i L_0 \\ T_3 &= E_k(checksum) \cdot 2^{max} \cdot 3 \cdot L_0 \\ &= E_k(message[0] \bigoplus ... \bigoplus message[max]) \cdot 2^{max} \cdot 3 \cdot L_0 \end{align}\]

    显然,利用第二步得到的本地 $E_k(?)$,很容易得到我们需要的 $(C_3,\;T_3)$。

更多攻击

相关的更多攻击列举如下,详见相关论文。

  • forgery of longer messages
  • universal forgery
  • distinguishing attack
  • plaintext recovery
  • simulation of block cipher encryption
  • simulation of block cipher decryption

6. 例子:红明谷杯 2021 babyForgery

题目

以红明谷杯的那道题目为例,用的是关于 AES.OCB2Universal Forgery

题目如下:

#!/usr/bin/env python3
# -*- coding: utf-8 -*-
import os
import string
import random
import socketserver
import signal
from hashlib import sha256

from ocb.aes import AES # https://github.com/kravietz/pyOCB
from ocb import OCB

FLAG = #####REDACTED#####

BLOCKSIZE = 16
MENU = br"""
[1] Encrypt
[2] Decrypt
[3] Get Flag
[4] Exit
"""

class Task(socketserver.BaseRequestHandler):
    def _recvall(self):
        BUFF_SIZE = 2048
        data = b''
        while True:
            part = self.request.recv(BUFF_SIZE)
            data += part
            if len(part) < BUFF_SIZE:
                break
        return data.strip()

    def send(self, msg, newline=True):
        try:
            if newline: msg += b'\n'
            self.request.sendall(msg)
        except:
            pass

    def recv(self, prompt=b'> '):
        self.send(prompt, newline=False)
        return self._recvall()

    def recvhex(self, prompt=b'> '):
        return bytes.fromhex(self.recv(prompt=prompt).decode('latin-1'))

    def proof_of_work(self):
        random.seed(os.urandom(128))
        proof = ''.join(random.choices(string.ascii_letters+string.digits, k=20))
        _hexdigest = sha256(proof.encode()).hexdigest()
        self.send(str.encode("sha256(XXXX+%s) == %s" % (proof[4:], _hexdigest)))
        x = self.recv(prompt=b'Give me XXXX: ')
        if len(x) != 4 or sha256(x+proof[4:].encode()).hexdigest() != _hexdigest:
            return False
        return True

    def timeout_handler(self, signum, frame):
        self.send(b"\n\nTIMEOUT!!!\n")
        raise TimeoutError

    def encrypt(self, nonce, message, associate_data=b''):
        assert nonce not in self.NONCEs
        self.NONCEs.add(nonce)
        self.ocb.setNonce(nonce)
        tag, cipher = self.ocb.encrypt(bytearray(message), bytearray(associate_data))
        return (bytes(cipher), bytes(tag))
    
    def decrypt(self, nonce, cipher, tag, associate_data=b''):
        self.ocb.setNonce(nonce)
        authenticated, message = self.ocb.decrypt(
            *map(bytearray, (associate_data, cipher, tag))
        )
        if not authenticated:
            raise ValueError('REJECT')
        return bytes(message)

    def handle(self):
        signal.signal(signal.SIGALRM, self.timeout_handler)
        signal.alarm(60)
        if not self.proof_of_work():
            return

        aes = AES(128)
        self.ocb = OCB(aes)
        KEY = os.urandom(BLOCKSIZE)
        self.ocb.setKey(KEY)
        self.NONCEs = set()

        while True:
            USERNAME = self.recv(prompt=b'Enter username > ')
            if len(USERNAME) > BLOCKSIZE:
                self.send(b"I can't remember long names")
                continue
            if USERNAME == b'Alice':
                self.send(b'Name already used')
                continue
            break

        signal.alarm(60)
        while True:
            self.send(MENU, newline=False)
            try:
                choice = int(self.recv(prompt=b'Enter option > '))

                if choice == 1:
                    nonce = self.recvhex(prompt=b'Enter nonce > ')
                    message = self.recvhex(prompt=b'Enter message > ')
                    associate_data = b'from ' + USERNAME
                    ciphertext, tag = self.encrypt(nonce, message, associate_data)
                    self.send(str.encode(f"ciphertext: {ciphertext.hex()}"))
                    self.send(str.encode(f"tag: {tag.hex()}"))

                elif choice == 2:
                    nonce = self.recvhex(prompt=b'Enter nonce > ')
                    ciphertext = self.recvhex(prompt=b'Enter ciphertext > ')
                    tag = self.recvhex(prompt=b'Enter tag > ')
                    associate_data = self.recvhex(prompt=b'Enter associate data > ')
                    message = self.decrypt(nonce, ciphertext, tag, associate_data)
                    self.send(str.encode(f"message: {message.hex()}"))

                elif choice == 3:
                    nonce = self.recvhex(prompt=b'Enter nonce > ')
                    ciphertext = self.recvhex(prompt=b'Enter ciphertext > ')
                    tag = self.recvhex(prompt=b'Enter tag > ')
                    associate_data = b'from Alice'
                    message = self.decrypt(nonce, ciphertext, tag, associate_data)
                    if message == b'please_give_me_the_flag':
                        self.send(FLAG)

                elif choice == 4:
                    break
                else:
                    break

            except:
                self.send(b'Error!')
                break
        signal.alarm(0)

        self.send(b'Bye!')
        self.request.close()

class ForkedServer(socketserver.ForkingMixIn, socketserver.TCPServer):
    pass


if __name__ == "__main__":
    HOST, PORT = '0.0.0.0', 10000
    print(HOST, PORT)
    server = ForkedServer((HOST, PORT), Task)
    server.allow_reuse_address = True
    server.serve_forever()

分析

攻击原理正如我上面所述,题目不允许我们用 from Alice 的名义加密 please_give_me_the_flag,但要求我们提供的 $(N,C,T)$ 在 from Alice 的名义下解密为 please_give_me_the_flag

因此我们需要想办法来伪造。由于 associated data 的部分我们自己在本地就可以跑,所以伪造的核心就是想办法在本地把 $E_k(?)$ 也跑起来。具体方法正如我上个部分介绍 Universal Forgery 原理时所述。

既然在本地能跑整个加密部分,那我们就可以无视在线加密部分的不允许用 from Alice 的名义这一限制,在本地用 from Alice 的名义加密 please_give_me_the_flag,把结果提交让题目给我们解密即可获取 flag

代码

Dawn_whisper 师傅的 博客 上有直接可用的代码,我懒得写了 搬运如下:

from Crypto.Util.number import *
from gmpy2 import *
from pwn import *
import os

def times2(input_data,blocksize = 16):
    assert len(input_data) == blocksize
    output =  bytearray(blocksize)
    carry = input_data[0] >> 7
    for i in range(len(input_data) - 1):
        output[i] = ((input_data[i] << 1) | (input_data[i + 1] >> 7)) % 256
    output[-1] = ((input_data[-1] << 1) ^ (carry * 0x87)) % 256
    assert len(output) == blocksize
    return output

def times3(input_data):
    assert len(input_data) == 16
    output = times2(input_data)
    output = xor_block(output, input_data)
    assert len(output) == 16
    return output

def back_times2(output_data,blocksize = 16):
    assert len(output_data) == blocksize
    input_data =  bytearray(blocksize)
    carry = output_data[-1] & 1
    for i in range(len(output_data) - 1,0,-1):
        input_data[i] = (output_data[i] >> 1) | ((output_data[i-1] % 2) << 7)
    input_data[0] = (carry << 7) | (output_data[0] >> 1)
    # print(carry)
    if(carry):
        input_data[-1] = ((output_data[-1] ^ (carry * 0x87)) >> 1) | ((output_data[-2] % 2) << 7)
    assert len(input_data) == blocksize
    return input_data

def xor_block(input1, input2):
    assert len(input1) == len(input2)
    output = bytearray()
    for i in range(len(input1)):
        output.append(input1[i] ^ input2[i])
    return output

def hex_to_bytes(input):
    return bytearray(long_to_bytes(int(input,16)))

context(log_level='debug')
r=remote("127.0.0.1","10000")

# login
r.recvuntil("Enter username > ")
r.sendline("aaa")

def Arbitrary_encrypt(msg):
    # to get aes.encrypt(msg)

    num = bytearray(os.urandom(16))
    # encrypt "\x00"*15+"\x80"+"\x00"*16
    r.recvuntil("Enter option > ")
    r.sendline("1")
    r.recvuntil("Enter nonce > ")
    r.sendline(num.hex())
    r.recvuntil("Enter message > ")
    m = bytearray(b"\x00"*15 + b"\x80" + b"\x00"*16)
    r.sendline(m.hex())
    r.recvuntil("ciphertext: ")
    cipher = r.recvline(False)
    r.recvuntil("tag: ")
    tag = r.recvline(False)

    # decrypt to solve L=E(nonce)
    r.recvuntil("Enter option > ")
    r.sendline("2")
    r.recvuntil("Enter nonce > ")
    r.sendline(num.hex())
    r.recvuntil("Enter ciphertext > ")
    m0 = bytearray(b"\x00"*15 + b"\x80")
    m1 = bytearray(b"\x00"*16)
    c0 = hex_to_bytes(cipher[:32])
    r.sendline(xor_block(c0,m0).hex())
    r.recvuntil("Enter tag > ")
    c1 = cipher[32:]
    r.sendline(c1)
    r.recvuntil("Enter associate data > ")
    r.sendline("")
    r.recvuntil("message: ")
    enc = xor_block(bytearray(hex_to_bytes(r.recvline(False))),m0)

    L = back_times2(enc)
    LL = enc
    LLL = xor_block(LL,c0)
    # print(L)
    # print(LL)
    # print(LLL)
    # L=L 2L=LL L'=LLL m0=m0
    msg = bytearray(msg)

    # encrypt msg
    r.recvuntil("Enter option > ")
    r.sendline("1")
    r.recvuntil("Enter nonce > ")
    r.sendline(xor_block(LL,m0).hex())
    r.recvuntil("Enter message > ")
    r.sendline(xor_block(msg,times2(LLL)).hex()+m1.hex())
    r.recvuntil("ciphertext: ")
    enc = bytearray(hex_to_bytes(r.recvline(False))[:16])
    r.recvline()
    return xor_block(enc,times2(LLL))

def my_pmac(header, blocksize = 16):
    assert len(header)
    m = int(max(1, math.ceil(len(header) / float(blocksize))))
    offset = Arbitrary_encrypt(bytearray([0] * blocksize))
    offset = times3(offset)
    offset = times3(offset)
    checksum = bytearray(blocksize)
    for i in range(m - 1):
        offset = times2(offset)
        H_i = header[(i * blocksize):(i * blocksize) + blocksize]
        assert len(H_i) == blocksize
        xoffset = xor_block(H_i, offset)
        encrypted = Arbitrary_encrypt(xoffset)
        checksum = xor_block(checksum, encrypted)
    offset = times2(offset)
    H_m = header[((m - 1) * blocksize):]
    assert len(H_m) <= blocksize
    if len(H_m) == blocksize:
        offset = times3(offset)
        checksum = xor_block(checksum, H_m)
    else:
        H_m.append(int('10000000', 2))
        while len(H_m) < blocksize:
            H_m.append(0)
        assert len(H_m) == blocksize
        
        checksum = xor_block(checksum, H_m)
        offset = times3(offset)
        offset = times3(offset)
    final_xor = xor_block(offset, checksum)
    auth = Arbitrary_encrypt(final_xor)
    return auth

def my_ocb_encrypt(plaintext, header, nonce, blocksize = 16):
    assert nonce
    m = int(max(1, math.ceil(len(plaintext) / float(blocksize))))
    offset = Arbitrary_encrypt(nonce)
    checksum = bytearray(blocksize)
    ciphertext = bytearray()
    for i in range(m - 1):
        offset = times2(offset)
        M_i = plaintext[(i * blocksize):(i * blocksize) + blocksize]
        assert len(M_i) == blocksize
        checksum = xor_block(checksum, M_i)
        xoffset = Arbitrary_encrypt(xor_block(M_i, offset))
        ciphertext += xor_block(offset, xoffset)
        assert len(ciphertext) % blocksize == 0
    M_m = plaintext[((m - 1) * blocksize):]
    offset = times2(offset)
    bitlength = len(M_m) * 8
    assert bitlength <= blocksize * 8
    tmp = bytearray(blocksize)
    tmp[-1] = bitlength
    pad = Arbitrary_encrypt(xor_block(tmp, offset))
    tmp = bytearray()
    C_m = xor_block(M_m, pad[:len(M_m)])
    ciphertext += C_m
    tmp = M_m + pad[len(M_m):]
    assert len(tmp) == blocksize
    checksum = xor_block(tmp, checksum)
    offset = times3(offset)
    tag = Arbitrary_encrypt(xor_block(checksum, offset))
    if len(header) > 0:
        tag = xor_block(tag, my_pmac(header))
    return (tag, ciphertext)

finalnonce = bytearray(hex_to_bytes('11'*16))
finaltag,finalcipher = (my_ocb_encrypt(bytearray(b'please_give_me_the_flag'),bytearray(b'from Alice'),finalnonce))

finaltag = finaltag.hex()
finalnonce = finalnonce.hex()
finalcipher = finalcipher.hex()
# print(finaltag)
# print(finalnonce)
# print(finalcipher)

r.recvuntil("Enter option > ")
r.sendline("3")
r.recvuntil("Enter nonce > ")
r.sendline(finalnonce)
r.recvuntil("Enter ciphertext > ")
r.sendline(finalcipher)
r.recvuntil("Enter tag > ")
r.sendline(finaltag)

flag = r.recvline()
if(b'flag' in flag):
    print(flag)

# r.interactive()

7. CTF 相关题目:对 OTR 的攻击

简介

AES-OTRCAESAR 竞赛中很著名的一个方案。它的提出者恰好是提出对 OCB2 的攻击的 Akiko Inoue 的导师 Kazuhiko Minematsu。它的设计基于 Feistel 结构。

AES-OTR 是一个分组密码认证加密工作模式并以 AES 示例。

AES-OTR 包含两个方案。两个方案的不同主要在于关联数据 A 的处理过程,一个方案对于关联数据 A 的处理类似 CMAC,是串行操作,并且关联数据 A 处理后的输出参与加密的掩码生成,因此,明文 P 的加密过程和关联数据 A 的处理不能平行操作。另一个方案对于关联数据 A 的处理类似 PMAC,可并行操作。

明文 P 的加密采用两轮 Feistel 变换,将明文 P 划分为 128 比特的数据块,每两块数据的加密过程是以 AES 为轮函数的两轮 Feistel 变换。

为了保证可证明安全性,每两块数据的处理使用不同的掩码,区别对待最后一块或两块明文的处理方式。

摘要的生成方式是对明文块的和做处理后的输出。因为明文 P 的加密过程和关联数据 A 是独立的,所以可以并行处理。

AES-OTR 是一个在线、一步操作、比率为 1、只需 1 个密钥、并行加解密的认证加密算法,其设计特点主要体现在用两轮 Feistel 变换避免调用 AES 解密变换。BostSanders 指出 OTR 的掩码生成导致其安全证明存在问题,因此,最新版 OTR 修改了掩码。

第一个版本的 AES-OTR 图示如下:

XNUCA 2020 imposter

第一个版本的 AES-OTR 被指出其掩码生成导致安全证明存在问题。

而网上确实有几篇对第一个版本 AES-OTR 攻击的论文。XNUCA 2020 的一道题目 imposter 就参考了其中一篇论文。

详细 writeup 参考 writeups for XNUCA 2020 Qualifier - NeSE。官方 writeup 和它参考的论文说的都很清楚,这里就不赘述了。


声明:本文采用 CC BY-NC-SA 4.0 授权。

This work is licensed under a CC BY-NC-SA 4.0.