포너블 2주차 과제 드림핵 요약
*소프트웨어 버그 * 프로그램이 잘못된 결과를 내거나, 오류를 발생하는 등의 의도치 않은 동작을 수행하게 되는 문제
소프트웨어 취약점 공격자가 주어진 권한 이상의 권한을 획득하거나 프로그래머가 의도하지 않은 동작을 수행할 수 있도록 하는 소프트웨어 버그를 의미
==> 보안 전문가들은 취약점을 패치해 프로그램을 보호한다.
익스플로잇 악용, 취약점을 이용해 공격자가 의도한 동작을 수행하게 하는 코드 혹은 이를 이용한 공격 행위
취약점이 공격자의 의도를 달성하는 데 어느 정도 도움을 주는지를 기준으로 소프트웨어 버그는 4가지로 분류할 수 있다.
*소프트웨어 버그(Bug) * 가장 상위에 있다.
ㄴ *소프트웨어 취약점(Vulnerability) * 소프트웨어 버그 중 보안에 영향을 미칠 수 있는 버그
ㄴ **익스플로잇 가능한 취약점(Exploitable Vulnerability)** 소프트웨어 취약점 중 이를 이용해 공격자가 의도한 동작을 수행할 수 있는
버그
ㄴ **안정적으로 익스플로잇 가능한 취약점(Reliably Exploitable Vulnerability) ** 익스플로잇이 가능한 취약점 중 성공 확률이 높은 버그
Attack Vector(공격 벡터) 소프트웨어 취약점은 사용자의 입력에서부터 발생, 이렇게 공격자가 소프트웨어와 상호 작용할 수 있는 곳!
Attack Surface 공격 벡터들의 집합
취약점 공격 방법
C/C+과 같은 저수준 언어에서 메모리를 조작해 공격하는 메모리 커럽션 취약점, 메모리를 조작할 필요 없이 공격할 수 있는 로지컬 취약점
먼저 메모리 커럽션 취약점의 대표적인 예시들로는 Buffer Overflow, Out-Of-Boundary,Off-by-one, Format String Bug, Double Free / User-After-Free 등등이 있다.
Buffer Overflow 메모리 커럽션 취약점 중 가장 대표적인 취약점. 프로그래머의 의도대로 할당한 크기의 버퍼보다 더 큰 데이터를 입력받아 메모리의 다른 영역을 오염시킬 수 있는 취약점. 아직까지 핫함
*Out-Of-Boundary(OOB) * 버퍼의 길이 범위를 벗어나는 곳의 데이터에 접근할 수 있는 취약점. 이것도 핫함
*Off-by-one * 경계 검사에서 하나 더 많은 값을 쓸 수 있을 때 발생하는 취약점. 32바이트 크기의 버퍼에 인덱스 32로 접근하는 것 같은 경우
*Format String Bug * printf나 sprintf와 같은 함수에서 포맷 스트링 문자열을 올바르게 사용하지 못해 발생하는 취약점
Double Free / Use-After-Free 동적 할당된 메모리를 철저하게 관리하지 못했을때 발생. Double Free는 이미 해제된 메모리를 한 번 더 해제하려고 시도하는 것, Use-After-free는 해제된 메모리에 접근해 이를 사용하려고 하는 것
로지컬 버그의 대표적인 예시로는 Command Injection, Race Condition, Path Traversal 등등이다.
*Command Injection * 사용자의 입력을 셀에 전달해 실행할 때 정확한 검사를 실행하지 않아 발생하는 취약점. 공격자가 원하는 명령을 실행할 수도 있다.
Race Condition 여러 스레드나 프로세스의 자원 관리를 정확히 수행하지 못해 데이터가 오염되는 취약점. 발생 원인과 공격 방법에 따라 메모리 커럽션 취약점으로도, 로지컬 취약점으로도 분류 가능한 취약점.
Path Traversal 프로그래머가 가정한 디렉토리를 벗어나 외부에 존재하는 파일에 접근할 수 있는 취약점. 주로 ".../"이 위험
*미티게이션 * 취약점의 공격을 어렵게 만드는 일을 하는 것
버퍼 오버플로우
C언어에서 버퍼란 지정된 크기의 메모리 공간이라는 뜻. 버퍼가 허용할 수 있는 양의 데이터보다 더 많은 값이 저장되어 버퍼가 넘치는 취약점. 입접한 메모리를 오염시키는 취약점이기 때문에 어떤 메모리를 오염시킬 수 있는지에 따라 공격 방법이 달라진다.
- 스택 오버플로우 지역변수가 할당되는 스택 메모리에서 오버플로우가 발생하는 경우
버퍼 A와 버퍼 B에 9바이트씩 할당되어 있을때 A에 16 바이트의 데이터를 복사한다면 그 데이터는 버퍼 B의 영역까지 오염시킨다.
이때 우리는 버퍼 오버플로우가 발생했다고 하고, 이는 프로그램의 Undefined Behavior을 이끌어낸다. 만약 버퍼 B에 다음에 호출될 함수 포인터를 저장하고 있다면 그곳에 "AAAAAAAA"로 덮었을때 Segmentation Fault(접근 권한이 없는 메모리 영역을 읽거나 쓰려고 할때 발생하는 예외)를 발생시킨다.
// stack-1.c
#include <stdio.h>
#include <stdlib.h>
int main(void) {
char buf[16];
gets(buf);
printf("%s", buf);
}
16 바이트 버퍼 buf를 스택에 할당하고 gets를 통해 사용자로부터 데이터를 입력받아 buf에 저장하고 그 데이터를 출력하는 코드다.
gets는 별도의 길이 제한이 없기 때문에 16바이트가 넘는 데이터가 와도 그냥 담아버린다. 그 결과 스택 오버플로우가 발생한다.
// stack-2.c
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
int check_auth(char *password) {
int auth = 0;
char temp[16];
strncpy(temp, password, strlen(password));
if(!strcmp(temp, "SECRET_PASSWORD"))
auth = 1;
return auth;
}
int main(int argc, char *argv[]) {
if (argc != 2) {
printf("Usage: ./stack-1 ADMIN_PASSWORD\n");
exit(-1);
}
if (check_auth(argv[1]))
printf("Hello Admin!\n");
else
printf("Access Denied!\n");
}
main 함수에서 check함수를 호출하면, 4바이트 크기의 auth라는 버퍼와 16바이트 크기의 temp라는 버퍼를 메모리에 할당시킨다.
사용자가 입력한 비밀번호는 password라는 문자열 포인터 형태로 주어졌다. temp라는 16바이트 버퍼에 password 문자열을 그 문자열의 길이만큼 복사를 진행한다 . 여기서 사용자가 입력한 password 문자열의 길이를 모르는 상태에서 16바이트 크기에 넣으면 메모리상에서 temp 버퍼 뒤에 있는 auth 버퍼의 값을 오염시킨다 .auth는 로그인이 성공하냐 안하냐를 리턴하는 중요한 버퍼인데 그 곳이 문자열로 오염이 된다면 main 함수의 if 문의 조건문은 항상 참이 되며 비밀번호를 뭘 입력하더라도 항상 로그인이 되게 된다.
// stack-3.c
#include <stdio.h>
#include <unistd.h>
int main(void) {
char win[4];
int size;
char buf[24];
scanf("%d", &size);
read(0, buf, size);
if (strncmp(win, "ABCD", 4)){
printf("Theori{-----------redeacted---------}");
}
}
main 함수에서 4 바이트 크기의 버퍼 win 4 바이트 크기의 버퍼 size, 24비트 크기의 버퍼 buf를 메모리에 할당했다.
scanf 를 통해 size 변수에 값을 입력받고, read 로 buf 에 size 크기의 데이터를 입력받는다. 여기서 버퍼보다 더 긴 데이터를 입력받아 스택 버퍼 오버플로우가 발생할 수 있다.
// stack-4.c
#include <stdio.h>
int main(void) {
char buf[32] = {0, };
read(0, buf, 31);
sprintf(buf, "Your Input is: %s\n", buf);
puts(buf);
}
32바이트 크기의 버프 buf를 초기화하고 31바이트 크기의 데이터를 입력받고 있다.
sprintf 함수를 통해 버퍼에 값을 쓸 때 문자열을 추가한다는 사실을 생각해야함. 31바이트를 꽉 채운다면 오붤플로우~
// heap-1.c
#include <stdio.h>
#include <stdlib.h>
int main(void) {
char *input = malloc(40);
char *hello = malloc(40);
memset(input, 0, 40);
memset(hello, 0, 40);
strcpy(hello, "HI!");
read(0, input, 100);
printf("Input: %s\n", input);
printf("hello: %s\n", hello);
}
방금까지는 메모리의 스택 영역에서 이루어지는 스택 버퍼 오버플로우의 예를 살펴봤다. 힙 오버플로우는 단지 발생하는 메모리 영역의 차이만 있을 뿐이고 취약점이 발생하는 원인이 본질적으로 다르진 않다.
위의 heap-1.c에서는 malloc( heap 영역에 메모리를 잡아주는 역할을 한다. )을 사용해 40 바이트 크기의 버퍼 input과 hello를 할당한 후
memset(배열을 초기화할 때 많이 사용하는 함수, 위의 코드에서는 input과 hello 배열을 0부터 40크기만큼 초기화시킨다.)을 사용해 버퍼를 초기화하고, hello 버퍼에 "HI!"를 복사한다. read를 사용해 input 버퍼에 100바이트 크기의 데이터를 입력받는다. 여기서 input 버퍼는 40바이트 크기인데 100바이트 크기의 데이터로 덮으면 힙 오버플로우가 발생한다.
*Out Of Boundary *버퍼의 길이 범위를 벗어나는 인덱스에 접근할 때 발생하는 취약점
// oob-1.c
#include <stdio.h>
int main(void) {
int win;
int idx;
int buf[10];
printf("Which index? ");
scanf("%d", &idx);
printf("Value: ");
scanf("%d", &buf[idx]);
printf("idx: %d, value: %d\n", idx, buf[idx]);
if(win == 31337){
printf("Theori{-----------redeacted---------}");
}
}
idx 변수에 사용자로부터 인덱스 값을 입력받고 buf[idx]에 값을 입력받는다. buf는 크기가 40바이트인 int형 버프이기 때문에 idx는 0~9까지의 정수만 허용된다. 허용되는 범위 밖의 숫자를 입력받게 된다면 OOB가 발생한다.
// oob-2.c
#include <stdio.h>
int main(void) {
int idx;
int buf[10];
int win;
printf("Which index? ");
scanf("%d", &idx);
idx = idx % 10;
printf("Value: ");
scanf("%d", &buf[idx]);
printf("idx: %d, value: %d\n", idx, buf[idx]);
if(win == 31337){
printf("Theori{-----------redeacted---------}");
}
}
방금 전 코드와 변수 선언과 index 값을 입력받는 것까지는 똑같다. 근데 아까처럼 idx에 11을 할당하면 원하는 목적을 이룰 수 없다.
idx=idx%10이 idx의 값이 10이상으로 커지는 것을 막는다. 언뜻 보기에는 OOB를 막은거서럼 보인다. 하지만 buf의 인덱스로 쓸 수 있는 값의 범위는 -9~9이기 때문에 음수를 idx에 입력하면 idx 값은 음수를 갖게 된다. 이렇게 하면 OOB를 발생시킬 수 있다. idx에 -1을 대입시키면 win 버퍼에 접근 가능하다.
//oob-3.c
#include <stdio.h>
int main(void) {
int idx;
int buf[10];
int dummy[7];
int win;
printf("Which index? ");
scanf("%d", &idx);
if(idx < 0)
idx = -idx;
idx = idx % 10; // No more OOB!@!#!
printf("Value: ");
scanf("%d", &buf[idx]);
printf("idx: %d, value: %d\n", idx, buf[idx]);
if(win == 31337){
printf("Theori{-----------redeacted---------}");
}
}
예제와 정답이 따로 나뉘어있는걸 보니 문제인거 같다. 그리고 졸리다.
문제에 pow라는 놈이 있다. pow는 제곱을 해주는 함수로서 pow(2,5)는 2의 5승 값을 반환한다.
왜 -pow(2,31)은 if 문에서 못걸렀을까....int형의 범위 때문인가? 와 씨
맞았다. 갓삭님의 멘토링 덕분인거같다. 이 영광을 갓삭님께 돌립니다 :)
int 형에서 -pow(2,31)은 표현 가능하지만 pow(2,31)은 표현 가능하지 않다.
pow(2,31)은 표현 가능한 최대 정수보다 하나 더 크기 때문에 이는 -pow(2,31)과 같은 값이 된다!
idx에 -pow(2,31)을 넣으면 절대값 조건문 지나도 -2**31이 저장되어 있다. 그럼 그 다음으로 idx=idx%10 결과 음수가 저장되고
이로 인해 OOB 쌉가능.
Off-by-one 이름은 젤 간지난다. 경계 검사에서 하나의 오차가 있을 때 발생하는 취약점. 버퍼의 경계 계산 혹은 반복문의 횟수 계산 시 < 대신 <=을 쓰거나, 0부터 시작하는 인덱스를 고려하지 못할 때 발생.
// off-by-one-1.c
#include <stdio.h>
void copy_buf(char *buf, int sz) {
char temp[16];
for(i = 0; i <= sz; i++)
temp[i] = buf[i];
}
int main(void) {
char buf[16];
read(0, buf, 16);
copy_buf(buf, sizeof(buf));
}
16 바이트 크기의 버프 buf에 16 바이트 크기의 데이터를 입력받는다. 그리고 copy_buf 함수를 실행한다.
16 바이트 크기의 버프 temp를 메모리에 할당하고 반목문으로 들어가 버프 temp에 버프 buf 데이터를 하나하나 복사한다. 그런데 여기서
반복문의 표현식을 보면 for(int i=0 ; i<=sz ; i++) sz는 buf의 사이즈다. 저렇게 된 코드는 총 17번을 복사한다. 즉 temp[17]=bur[17]까지 실행이 된다는 말이다. 그 결과 OFF-BY-ONE 취약점이 발생한다.
Review~
*스택 버퍼 오버플로우 *
스택 버퍼 오버플로우는 가장 초기에 등장한 버퍼 오버플로우 형태 중 하나, 지역 변수가 할당되는 스택 메모리에서 발생하는 취약점. 데이터를 입력 받을때나 복사할때 길이 검증이 제대로 이루어지지 않아 발생
힙 오버플로우
힘 오버 플로우는 동적으로 할당된 힙 메모리 영역에서 발생하는 취약점. 이것도 데이터를 입력받거나 복사하는 부분에 대한 길이 검증이 제대로 이루어지지 않아 발생.
Out-Of-Bounday 오오비로 줄여서 말하는게 포인트
Out-Of-Boundary는 버퍼의 길이 범위를 벗어나는 인덱스에 접근할 때 발생하는 취약점. 버퍼의 인덱스 값이 올바르지 않을 때 발생
*Off-by-one *
Off-by-one은 버퍼의 경계 계산 혹은 잘못된 반복문의 연산자를 사용하는 등의 인덱스를 고려하지 않을때 발생
포맷 스트링 버그 지정한 문자열이 아닌 사용자의 입력이 포맷스트링으로 전달될 때 발생하는 취약점
// fsb-1.c
#include <stdio.h>
int main(void) {
char buf[100] = {0, };
read(0, buf, 100);
printf(buf);
}
buf에 포맷 스트링이 들어가지 않은 일반적인 문자열을 입력한다면 아무 문제없이 printf로 출력 될것이다.
하지만 buf에 "%x, %d"와 같은 포맷 스트링을 문자열로 입력한다면 printf(buf)는 printf("%x %d")와 같고 %x와 %d에 쓰일 두,세 번째 인자가 전달되지 않았기에 쓰레기 값을 인자로 취급해 출력한다.
#include <stdio.h>
int main(void) {
int auth = 0x42424242;
char buf[32] = {0, };
read(0, buf, 32);
printf(buf);
if (auth == 0xff) {
printf("Success!!");
} else {
printf("Failed");
}
}
실습 뚜둥...
auth의 값을 0xff로 바꿔놔야 하네요...?
아직 잘 모르겠다 이거 큰일났다.
이거 저만 못풀었어요???????
Double Free & Use After Free
Double Free 취약점은 이미 해제된 메모리를 다시 한 번 해제하는 취약점이다.
Use After Free 취약점은 해제된 메모리에 접근해서 값을 쓸 수 있는 취약점이다.
#include <stdio.h>
#include <malloc.h>
int main(void) {
char* a = (char *)malloc(100);
char *b = (char *)malloc(100);
memset(a, 0, 100);
strcpy(a, "Hello World!");
memset(b, 0, 100);
strcpy(b, "Hello Pwnable!");
printf("%s\n", a);
printf("%s\n", b);
free(a);
free(b);
free(a);
}
마지막 줄에서 free된 메모리를 다시 free를 호출하는 것을 볼 수 있는데 저 코드는 우분투 18.04 환경에서 정상 종료된다. 즉 해제된 메모리를 다시 해제하는 것이 불가능하지 않다는 사실을 알 수 있따.
// uaf1.c
#include <stdio.h>
#include <string.h>
#include <malloc.h>
int main(void) {
char *a = (char *)malloc(100);
memset(a, 0, 100);
strcpy(a, "Hello World!");
printf("%s\n", a);
free(a);
char *b = (char *)malloc(100);
strcpy(b, "Hello Pwnable!");
printf("%s\n", b);
strcpy(a, "Hello World!");
printf("%s\n", b);
}
100바이트 크기의 힙 메모리 a를 할당하고
초기화하고
"Hello World!"를 복사한다.a를 출력하고 a 메모리를 해제한다.
그 다음 100바이트 크기의 힙 메모리 b를 할당하고 "Hello Pwnable!"을 복사한다.
힙 메모리 상태를 보면 포인터 a에 저장된 주소값은 바뀌지 않았다는 것과 메모리 a와 메모리 b가 같은 주소를 가리키고 있다는 점을 주목해야한다. 이미 해제되었던 메모리 a가 메모리 할당자로 들어가고 새로운 메모리 영역을 할당할때 효율성을 높히기 위해 해제되었던 메모리가 그대로 반환되어 일어난 일이다!
이 상황에서 이미 해제된 메모리 a에 접근하면 메모리 b도 같이 영향을 받기 때문에 위험쓰하다.
마지막 줄 쯤에 strcpy(a, "Hello World!");는 이미 해제된 메모리 포인터인 a가 대상이기 때문에 a와 b는 서로 같은 주소를 가리키고 있게된다. 값도 둘다 "Hello World!"
typedef struct person {
char *name;
int age;
} Person;
int main(void) {
Person p;
int name_len;
printf("Name length: ");
scanf("%d", &name_len);
if(name_len < 100)
p.name = (char *)malloc(name_len);
read(0, p.name, name_len);
printf("Age: ");
scanf("%d", &p.age);
printf("Name: %s\n", p.name);
printf("Age: %d\n", p.age);
}
초기화 되지 않은 메모리
위의 코드에서는 name에 할당된 메모리를 초기화 하는 memset함수가 보이지 않는다. read 함수는 입력받을 때 널바이트와 같은 별도의 구분자 붙이지 않음. => 이후 name을 출력하는 부분에서 초기화되지 않은 다른 메모리 출력될 가능성 있음
또한 name_len이 100 미만인 경우만 처리를 해놓고 100이상일때는 예외 처리가 없다. 만약 100이상의 수가 name_len에 입력되었다면 if문이 실행이 되지 않기 때문에 p.name은 malloc으로 할당된 값이 아니라 쓰레기 값이 된다.
Intergar issues
자료형 | 표현 가능한 값의 범위 |
(signed) char | -2^7 ~ 2^7-1 |
unsigned char | 0 ~ 2^8-1 |
(signed) short | -2^15 ~ 2^15-1 |
unsigned short | 0 ~ 2^16-1 |
(signed) int | -2^31 ~ 2^31-1 |
unsigned int | 0 ~ 2^32-1 |
(signed) long long | -2^63 ~ 2^63-1 |
unsigned long long | 0 ~ 2^64-1 |
묵시적 형변환 연산 시 연산의 피연산자로 오는 데이터들의 자료형이 서로 다를 경우, 다양한 종류의 형 변환이 일어나게 됩니다. 이때 프로그래머가 자료형을 직접 명시해주지 않는다면 묵시적으로 형 변환이 발생한다.'
- 대입연산의 경우 대입 연산자의 좌변과 우변의 자료형이 다를 경우 묵시적 형 변환이 일어난다.
- 정수 승격은 char이나 short같은 자료형이 연산될 때 일어난다. 이는 컴퓨터가 int형을 기반으로 연산하기 때문에 일어난다.
- 피연산자가 불일치할 경우 형 변환이 일어난다. int<long<long long<float<double<long double 순으로 변환댐
#include <stdio.h>
#include <stdlib.h>
int main(void) {
char *buf;
int len;
printf("Length: ");
scanf("%d", &len);
buf = (char *)malloc(len + 1);
if(!buf) {
printf("Error!");
return -1;
}
read(0, buf, len);
}
공격자가 len 값으로 -1을 넣었을때 어떻게 프로그램이 흘러갈까? len=-1이므로 buf=(char *)malloc(-1+1);이 실행이 된다. 리눅스에서는 malloc의 인자가 0이라면 정상적인 힙 메모리가 반환된다. 이후 맨 마지막 줄에서 read(0,buf,-1)이 실행이 되는데 read 함수의 세번째 인자는 size_t형이다. size_t형은 32비트에서 unsigned_int와 같기 때문에 -1은 2^32-1과 같다. 따라서 read(0, buf, pow(2,32)-1)과 같다. 그러므로 지정된 크기의 버퍼를 넘는 데이터를 넣을 수 있어 힙 오버플로우가 봘쇙
// int-2.c
char *create_tbl(unsigned int width, unsigned int height, char *row) {
unsigned int n;
int i;
char *buf;
n = width * height;
buf = (char *)malloc(n);
if(!buf)
return NULL;
for(i = 0; i < height; i++)
memcpy(&buf[i * width], row, width);
return buf;
}
width*height 크기의 버퍼를 할당하는데, width가 65536이고 height가 65537이면 그 값은 pow(2,32)+65536이라고 한다.
즉 unsigned int형에서는 pow(2,32) + 65536은 65536이 된다. 그러나!! memcpy 함수에서는 반복문을 순회하면서 메모리를 복사하기 때문에 버퍼 오버플로우가 발생하게 된다.
char *read_data(int fd) {
char *buf;
int length = get_int(fd); // length는 사용자가 입력할 수 있는 값입니다.
if(!(buf = (char *)malloc(MAX_SIZE))) // #define MAX_SIZE 0x8000
exit(-1);
if(length < 0 || length + 1 >= MAX_SIZE) {
free(buf);
exit(-1);
}
if(read(fd, buf, length) <= 0) {
free(buf);
exit(-1);
}
buf[length] = '\0';
return buf;
}
퀴즈다. 어느 라인이 제일 취약할까. 답은 두번째 if문.. 길이가 0보다 작거나 +1을 한 값이 0x8000보다 크다면 buff를 free하고 함수를 종료한다. 그럼 길이가 0보다 크고 length+1<MAX_SIZE인 값을 넣으면 길이 검사는 통과닷
int 정수의 최댓값을 넣는다면 당연히 0보다는 크니깐 첫번째는 통과. int형의 최댓값에 1을 더하면 무엇이겠나!!?? -2^32겠찌!!!!
그럼 바로 통과자너.
Review
포맷 스트링 버그(어렵다,,나쁜 놈)
printf나 sprintf 함수와 같이 포맷 스트링을 사용하는 함수들을 안전하게 쓰지않을 때 발생하는 취약점. 사용자의 입력이 포맷 스트링으로 전달될 수 있을때 발생.
Double Free & Use After Free
Double Free는 동적으로 할당된 하나의 힙 메모리를 두 번 해제할 때 발생하는 취약점.
Use After Free는 동적 할당 시 힙 메모리를 효율적으로 관리하기 위해 기존에 해제되었던 메모리가 반환되어 발생하는 취약점
초기화되지 않은 메모리
변수를 선언하거나 인스턴스를 생성할 때 초기화를 하지 않을 경우에 발생하는 취약점이다. 이는 할당된 변수가 쓰레기 값을 가지게 되면서 발생한다.
Inter issues
정수의 형 변환을 제대로 처리하지 못해 발생하는 문제. 보통 다른 취약점에 연계되어 사용댐.
너무 어렵다,,,다시 복습하여 완벽하게 이해하겠습니당,,
댓글
이 글 공유하기
다른 글
-
포너블 스터디 6주차 과젯! [Dreamhack wargame off_by_one 000-001]
포너블 스터디 6주차 과젯! [Dreamhack wargame off_by_one 000-001]
2020.05.29 -
포너블 스터디 2주차 과제 제출
포너블 스터디 2주차 과제 제출
2020.04.10 -
포너블 스터디 2주차(달고나 문서 요약)
포너블 스터디 2주차(달고나 문서 요약)
2020.04.10 -
Bandit 0~14
Bandit 0~14
2020.04.02