스터디 1주차~[미완]
드림핵 Reverse Engineering 강좌 수강하고~ 워게임 3개를 풀어볼려고 합니다!
1) 강좌 정리
https://dreamhack.io/lecture/curriculums/3
리버스 엔지니어링 리버싱이라고 흔히들 불리는 리버스 엔지니어링은 이미 만들어진 시스템이나 장치에 대한 해체나 분석을 거쳐 대상의 구조와 기능 등을 알아내는 과정을 말합니다. 완성품을 보고 설계도를 짐작해보는 느낌..? 그 중 소프트웨어 리버싱을 공부해보려고 합니당~
정적 분석 방법(Static Analysis)
대상 프로그램을 실행시키지 않고 분석하는 방법. 실행 파일을 구성하는 모든 요소, 대상 실행 파일이 실제로 동작할 CPU 아키텍처에 해당하는 어셈블리 코드를 이해할 수 있는 엄청난 실력이 필요,,
동적 분석 방법(Dynamic Analysis)
프로그램을 실행시켜서 입출력과 내부 동작 단계를 살피며 분석하는 방법. 실행 단계별로 자세한 동작 과정을 살펴봐야 하므로, 환경에 맞는 디버거를 이용해 단계별로 분석하는 엄청난 기술이 필요,,,
사람이 이해할 수 있는 소스코드를 컴퓨터가 이해할 수 있는 형태인 프로그램(바이너리)로 바꾸려면 '컴파일' 이라는 과정을 거쳐야합니다. 이를 해주는 프로그램을 '컴파일러'라고 부릅니다. 대부분의 바이너리들은 소스코드를 컴파일러로 컴파일 함으로써 생성됩니다.
소스코드 -> 중간언어 -> 어셈블리 코드 -> 기계어, 이 과정을 거쳐 바이너리가 생성되는데 리버싱은 이 과정을 역으로 진행합니다. 바이너리 코드(기계어)를 어셈블리 코드로 변환하는 과정을 Disassemble(디스어셈블)이라고 합니다.
CPU는 다음 실행할 명령어를 읽어오고(Fetch) 읽어온 명령어를 해석한 다음(Decode) 해석한 결과를 실행하는(Execute) 과정을 반복하는 장치입니다. 이렇게 한 개의 명령어(하나의 기계코드)가 실행되는 한 번의 과정을 Instruction Cycle이라고 합니다.
레지스터란 CPU가 사용하는 저장 공간입니다. 용도가 특별히 정해진 레지스터(명령어 포인터)와 특별히 정해지지 않고 두루두루 쓰이는 레지스터(범용 레지스터)로 나뉩니다.
범용 레지스퉈 용도를 특별히 정해두지 않고 쓸 수 있는 레지스터입니다.
나머지 부분은 https://caputdraconis.tistory.com/22?category=873930 에서 먼저 했었기에 스킵!
x64기초 강의에서는 어셈블리어의 기초 강좌가 진행된다. 예를 들어 위에서 스킵한 레지스터에 대한 설명과, 간단한 명령어(mov, lea, sub, add 등등..)의 설명과 이를 활용한 간단한 문제가 주어진다.
예를 들어 위와 같은 문제다.
mov rax, [rbx+8];
위 명령은 rbx+8이라는 주소값에 위치한 값을 rax에 옮긴다. 즉 rbx+8 => 0x401A48이라는 주소값에 위치한 값(*0x401A48), 0x00C0FFEE가 rax에 들어가게 된다.
lea rax, [rbx+8];
lea 명령은 값을 옮긴 mov 명령어와 달리 주소를 옮긴다. rbx에 담긴 주소 0x401A40에 +8된 값이 rax에 저장되게 됩니다.
허우 다음 탭은 너무 많은게 써있다..
추가설명할게 있다면 shl,shr과 sal,sar은 모두 똑같은 Shift Instructions이지만 차이점이 있다. shl,shr은 logical shift이므로 이동하며 생긴 빈 bit 자리에는 0이 채워지는 반면, sal과 sar은 arithmetic shift이기 때문에 부호가 보전된다. 따라서 부호를 나타내는 최상위비트가 shift이후에도 보전됩니다.
Conditional Operations는 분기문이나 조건문과 같이 코드의 실행 흐름을 제어하는 것과 밀접한 연관이 있는 명령어입니다. 이 명령어들의 결과에 따라 Flag가 영향을 받고 이에 따라 프로그램의 흐름이 달라지게 됩니다.
test rax,rax
test 명령어는 AND 논리연산을 하지만 그 결과값을 피연산자에 저장하지 않는다는 특징을 가진 명령어입니다.
and dst, src의 결과는 dst에 저장되지만 test 명령어는 그렇지 않습니다. 대신 test 명령어는 FLAGS 레지스터에 영향을 끼치게 됩니다. test 뒤에 온 두개의 피연산자에 대해 AND 연산을 수행하고 그 결과가 음수(최상위비트가 1)이면 SF가 1이 되고, AND 연산의 결과가 0이면 ZF를 1로 만듭니다. 따라서 이 test 명령어를 이용해 레지스터에 들어있는 값이 음수인지, 혹은 0인지를 확인하는데에 유용한 명령어라고 할 수 있습니다.
cmp rax,rdi
sub 명령어와 마찬가지로 앞 피연산자에서 뒤 피연산자의 값을 빼지만, 그 결과값이 앞 피연산자에 저장되지 않고 FLAGS 레지스터의 ZF와 CF 플래그에만 영향을 미친다는 점에서 test와 유사합니다. rax=rdi일 때에는 ZF=1, CF=0 rax<rdi일 때에는 ZF=0, CF=1 rax>rdi일 때에는 ZF=0, CF=0이 됩니다.
jmp와 jcc는 피연산자가 가리키는 곳으로 점프한다는 공통점을 같고 있지만, jmp는 무조건 점프하는 반면에 jcc는 FLAGS 레지스터의 플래그를 바탕으로 조건 점프를 한다는 차이점이 있습니다. 여기서 jcc는 명령어 이름이 아니라 조건부 jmp를 묶어서 이르는 이름입니다.
Stack Operations 지역 변수들이 저장되는 스택과 관련된 명령어들이다. 스택은 메모리에 준비된다. rsp는 스택의 가장 위쪽, 즉 마지막으로 데이터가 추가된 위치를 저장하는 레지스터이다. 흔히들 사용한 Intel x86-64 아키텍쳐에서 스택은 낮은 주소를 향해 자라기 때문에 rsp에 저장된 메모리 주소는 점점 낮아집니다.
함수가 시작되는 프롤로그 과정에서 rsp 레지스터에 들어있는 주소에서 충분한 값을 빼줌으로써, 이번 함수에서 사용할 스택 공간을 확보하는 과정을 거칩니다. 반대로 함수가 끝나는 에필로그 과정에서는 처음에 뺐던 값만큼 다시 rsp에 더해줌으로써 함수에서 사용했던 스택을 정리하는 효과를 볼 수 있습니다.
push 와 pop
스택에 새로운 데이터를 추가하고 뺄 때 사용하는 명령어입니다. push가 하는 일은 다음과 같습니다. 새롭게 넣을 데이터의 사이즈만큼 rsp에 들어있는 주소에서 빼줌으로써 새로운 데이터가 들어갈 공간을 마련해주고, 그 영역에 새로운 데이터를 넣습니다.
pop은 push와 반대로 스택의 최상단에 있는 데이터를 빼고 그 데이터의 크기만큼 rsp에 들어있는 주소에 더해줌으로써 자신이 머문 장소를 깨끗하게 보존합니다.
함수를 호출하는 명령어와 함수를 종료하는 명령어로는 call과 ret이 있습니다.
call은 피연산자로 실행할 함수의 주소를 받으면, 해당 주소로 이동해 함수를 실행하고 다음 명령어를 실행할 장소로 돌아와야합니다. 그렇기에 다음 명령어의 주소, 즉 Return Address를 스택에 PUSH!해 둔 다음, 호출할 함수의 주소로 jmp 하는 것과 동일한 원리로 실행됩니다.
ret 명령어는 호출된 함수가 마지막으로 사용하는 명령어입니다. call 명령을 호출할 때 스택에 push해 둔 Return Address를 pop하여 명령어 포인터인 rip 레지스터에 넣은 다음, 그 주소로 jmp하는 것과 동일한 효과를 냅니다.
x64dbg를 사용하는 방법들을 마구마구 알려준다. 대부분 구글링으로 검색했을때 배운 기능들이였지만 딱 하나!!
문자열 검색,,,
예를 들어 어느 프로그램을 실행시키면 INPUT: 이라고 써져있고 이 옆에 있는 칸에 입력값을 입력하면, 이 입력값에 따라 Correct! 또는 Wrong!이 출력되는 프로그램을 리버싱하고자 할 때, 수많은 함수들이 있을 것이고 그 중에서 이 입력값을 받고 처리하는 부분을 찾고자 한다면 굉장히 힘이 들 것이다. 그래서 필요한 기능이 바로 문자열 검색 기능이다.
오른쪽 위에 있는 Az 버튼을 클릭하면 현재 창에서 보고있는 모듈에 있는 문자열들을 참조하는 어셈블리어를 검색해준다!
강의 중간에 이렇게 풀어볼 수 있는 문제가 있다. 이걸 x64dbg가 있다.
input이라는 문자열 이후 정수 2개를 입력받는다.
그 "input:"이라는 문자열을 출력하는 부분을 대충 찾아보면 사용자의 입력을 받는 부분도 체크가 가능할거라고 생각한다!
길이가 5이상인 문자열을 검색해보자
이와 같은 결과가 나왔다. 그중에 저기 input:과 correct! wrong! 이 3개의 문자열을 찾을 수 있다. 그래서 correct!를 출력하는 주소를 찾아가 디컴파일된 코드를 보았다.
위의 함수에서 input: 이라는 문자열 출력 후 사용자로부터 %d %d 형태로 입력을 받은 후, 이의 값에 따라 wrong!과 correct!를 출력하는걸로 보이는데,,,,그럼 이게 main 함수이지 않을까 싶다! 그렇다면! 대충 우리가 현재 알고있는 함수로 찍어보자!
FUN_14001070은 "input:"이라는 문자열을 인자로 받는걸로 보아 대충 printf 함수로 추정된다...
그렇다면 이 다음 함수는!? FUN_14001120 함수는 "%d %d" 문자열을 인자로 넣고 뭐씨 뭐 인자 여러개 넣는거 보니깐 scanf 비슷한 함수가 아닐까 싶다....사실 잘 몰라잉~~~
위에 C코드에서 FUN_140001180 함수를 호출하는 부분의 어셈블리 코드다. FUN_140001180의 리턴값이 저장된 EAX를
TEST EAX, EAX를 이용해 EAX 값이 0인지 아닌지를 판단한다. 0이라면 LAB_140001180으로 점프해서 wrong! 을 출력하고 0이 아니라면 점프를 뛰지 않아 correct!를 출력한다. 결국 사용자의 입력값을 처리하는 부분은 FUN_140001180이라는 것을 알 수 있다!!!
그럼 이제 FUN_140001180 함수를 분석해보면 되겠다,,,후힣 후핳
인자로 주어진 두 정수는 0x2001, 즉 10진수로 8193 보다 작아야 한다. 그렇지 않으면,,, else 문으로 들어가게 되어 uVar1이 0으로 초기화될 것이다,,,
그리고 param_1 * param_2의 값은 0x6ae9bc, 즉 7006652이면서,,, param_1 / param_2의 값이 4이고!!! 또한 param_1과 param_2승을 XOR 연산 진행한 값이 0x12fc, 즉 10진수로 4860이여야 한다.. 이 3개의 조건을 모두 만족할 때 uVar1은 1로 초기화된다.
param_1과 param_2는,,,흐음 param_1 / param_2는 4보다 크거나 같고 5보다 작은 값이다. 허우씨 그냥 코드 짜서 실행시켜볼까여!?
#include <iostream>
#include <cmath>
using namespace std;
int main(){
bool hoxy=0;
for(int x{1};x<8193;x++){
for(int y{1};y<8193;y++){
if(x*y==7006652){
if(x/y==4){
if((x^y)==4860){
cout<<x<<" "<<y<<endl;
hoxy=1;
break;
}
}
}
}
if(hoxy==1)
break;
}
}
급하게 C++로 대충 결과만 맞게 나오는 코드를 짜봤다. 조건문 3개를 통과해 나온 값은 param_1이 5678, param_2이 1234이다!
다음 문제를 풀어보쟈~_~
easy-crackme2.exe 파일은 easy-crackme.exe 파일과 모습이 비슷했다. input: 이라는 문자열 출력 뒤에 correct!를 출력시키는 입력값을 구하면 되는 문제다!
일단 아까처럼 input:이라는 문자열의 위치를 찾아 main함수로 추정되는 함수를 선택해보자!
FUN_140001060이 "input:"이라는 문자열을 인자로 받는걸로 보아,, printf 비스무리한 친구로 보입니다...
그리고 iVar1에 getchar()로 사용자로부터 문자 하나를 입력받고, 그 위에 정해진 문자열(저 100, 0x77, 0x73...등을 아스키코드 표를 보고 하나하나 바꿔보면) dwsqawdu라는 문자열을 이어붙이게 된다. 그리고 FUN_1400011e0 함수를 호출하여 그 함수의 리턴값에 따라 correct! 또는 wrong!을 출력하게 됩니다.
그럼 FUN_1400011e0 함수 내에서 어떻게 값을 처리하는지 살펴보도록 하자!
ulonglong FUN_1400011e0(undefined *param_1,ulonglong param_2,undefined8 param_3,undefined8 param_4)
{
char cVar1;
int iVar2;
ulonglong uVar3;
uint uVar4;
int local_28;
int local_24;
uVar3 = FUN_1400010c0(*param_1,param_2,param_3,param_4);
local_28 = (int)uVar3;
local_24 = 1;
while (param_1[local_24] != '\0') {
cVar1 = param_1[local_24];
uVar3 = FUN_1400010c0(param_1[local_24 + 1],param_2,param_3,param_4);
uVar4 = (uint)param_2;
iVar2 = (int)uVar3;
if (cVar1 == 'a') {
local_28 = local_28 + iVar2;
}
else {
if (cVar1 == 'd') {
local_28 = local_28 * iVar2;
}
else {
if (cVar1 == 'f') {
uVar4 = local_28 % iVar2;
local_28 = local_28 / iVar2;
}
else {
if (cVar1 == 's') {
local_28 = local_28 - iVar2;
}
}
}
}
param_2 = param_2 & 0xffffffff00000000 | (ulonglong)uVar4;
local_24 = local_24 + 2;
}
return (ulonglong)(local_28 == 0x5b);
}
대충 이런 함수다.
어셈블리코드부터 살펴보자면 인자로 주어진 문자열 *param_1을 뚜시따시 하는 함수인거같다.
#1)))rsp+24를 반복문의 카운터로 사용, rsp+50 즉 param_1 문자열이 저장된 위치를 같이 사용함으로써 문자 하나하나를 비교하고 있다. param_1[rsp+24]의 값이 0인지 아닌지에 따라 점프를 수행하냐 안하냐가 갈리는데 만약 0일 경우 점프를 하게되는 1400012CA를 살펴보면
rsp+20의 값이 0x5B 이라면 0을 리턴하고 아니라면 1을 리턴하게 됩니다.
위 사진은 이 위에 찍어둔 #1에서 param_1[counter] == 0 을 만족하지 못하는 경우 오게되는 곳이다.
문자열이 저장되어 있는 rsp+50에 counter 변수값이 저장되어 있는rsp+24를 더한 값을 eax에 옮김으로써 다시 이 값을 rsp+34에 넣는다.
즉 rsp+34(tmp3로 명명)=argv[counter];이다.
#7) counter 변수에 +1 증가한 후, 이 값을 다시 argv의 인덱스로 사용하여 값을 가져온다. 즉 문자열의 다음 문자를 의미한다. 이 문자를 FUN_1400010C0 함수의 인자로 사용해 호출한다. 그리고 그 함수의 리턴값을 rsp+2c에 저장한다.. 이를 앞으로 tmp4로 명명한다.
아까 tmp3(rsp+34)에 저장했던 값을 eax에 옮기고 이를 rsp+28에 저장한다. 앞으로 rsp+28을 tmp5로 명명하자
바로 위에서 봤던 tmp5(rsp+28)과 'a', 'd', 'f', 's'를 비교하여 각각 원하는 위치로 보내지게 된다.
tmp5가 'a'일때 오게되는 부분이다. tmp4(rsp+2c)의 값과 rsp+20을 값을 가져와 이를 다시 rsp+20에 저장한다. 그리고 #14로 이동하게 된다.
tmp5가 'd'일때 오게 되는 부분이다. 이번에는 rsp+20 = [rsp+20] - [rsp+2C] 이다. 똑같이 #14로 이동한다.
tmp5가 'f'일때 오게 되는 부분이다. 이번에는 rsp+20 = [rsp+20] * tmp5이다. 다시 #14로 이동
tmp5가 's'일때 오게 되는 부분이다. 이번에는 rsp+20 = [rsp+20] / [rsp+2C]을 진행하고 #14로 이동한다.
위의 분기문에서 최종적으로 오게되는 #14다. counter변수가 저장되는 rsp+24의 값에 2를 더한다. 이후 #4로 점프합니다. 허우허우 이렇게 생각해본 함수의 흐름을 C코드로 정리해보면 아래와 같을 것이다.(이렇게 바꿀 수 있다는 사실에 깜짞깜짝 놀란다..)
이 위의 코드에서 if와 else if, 그리고 각 분기마다 꼭 거치게 되는 label2를 고려해 switch문으로 바꾸게 되면
호우! 어떻게 이런 생각을 하지,, 자 그럼 이제 sub_1400011E0 내에서 호출하는 sub_1400010C0 함수에 대해 분석해보자
인자로 받은 cl을 rsp+8에 저장하고 56바이트만큼 확장한다. rsp+8에 저장된 데이터에 접근하려면 확장된 스택 탓에 rsp+40으로 접근해야 한다. 이를 arg1으로 명명하자.
[rsp+20]을 0으로 초기화하고 rsp+24에 arg1을 넣고, 다시 이 값에서 0x65(10진수로 101)을 빼 rsp+24에 넣는다. 여기서부터 rsp+20을 tmp1, rsp+24를 tmp2로 명명한다.
tmp2와 0x14를 비교해 tmp2가 더 크면 #4로 점프를 뜁니다.
이 부분은 wrong input!을 출력하고 프로그램을 종료하게 되는 부분이다. 여기로 오면 안되겠다...라는 생각이 든다.
위 #3에서 tmp2가 0x14보다 더 크지 않은 값일때 오게되는 부분이다. rax에 tmp2를 복사하고, rcx에 0x140000000의 값을 넣는다. 또한 eax에는 [140000000 + tmp2*4 +0x1184]의 값을 넣는다잉,,, 여기서 0x140000000가 의미하는 바를 언뜻 눈치채기 힘들다. 하지만 지금까지 분석한 코드가 전부 0x14000...으로 시작한 점을 기억하고 있다면 이는 프로그램의 베이스 주소를 뜻한다는 것을 알 수 있다.
그렇게 다시 위의 코드를 살펴보면 0x140000000 + tmp2*4 = 0x1184에서 4바이트 값을 가져와서 다시 rcx(0x140000000)을 더한 후 주소값으로 점프해라!
tmp2가 0x10이라고 할때, 0x140000000+0x40+0x1184 => 0x1400011C4가 된다. 해당 영역으로 가보면
위와 같이은 값이 있고, 이를 리틀 엔디언 방식으로 읽으면 0x113f이다. 이 값에 0x140000000을 더하면 0x140000113f이므로 이 곳으로 점프하게 되면
[rsp+0x20]을 7로 설정한 후 LAB_14000117b로 이동한다. 이는 밑에서 볼 수 있듯이 #6임을 알 수 있다. 이 점프테이블을 모두 살펴보면 모두 tmp1([rsp+0x20])을 어떤 값으로 설정한 다음 #6으로 점프 뛰는 코드이다. tmp2의 값에 따라 tmp1에 어떤 값이 들어가는지 하나씩 살펴보면 다음과 같다.
x로 되어있는 부분은 #4(wrong input!을 출력하는 코드)로 점프뛰는 부분이다.
위 점프하는 부분을 C코드로 바꿔보자면!
int sub_1400010C0(char arg1) {
int tmp1 = 0;
int tmp2 = arg1;
tmp2 -= 0x65;
if(tmp2 > 0x14) goto fail;
if (tmp2 == 0) goto set3;
else if(tmp2 == 4) goto set8;
else if(tmp2 == 10) goto set9;
else if(tmp2 == 11) goto set0;
else if(tmp2 == 12) goto set1;
else if(tmp2 == 13) goto set4;
else if(tmp2 == 15) goto set5;
else if(tmp2 == 16) goto set7;
else if(tmp2 == 18) goto set2;
else if(tmp2 == 20) goto set6;
else goto fail; // tmp2 == 1, 2, 3, 5, 6, 7, 8, 9, 14, 17, 19
set0:
tmp1 = 0; goto ret;
set1:
tmp1 = 1; goto ret;
set2:
tmp1 = 2; goto ret;
set3:
tmp1 = 3; goto ret;
set4:
tmp1 = 4; goto ret;
set5:
tmp1 = 5; goto ret;
set6:
tmp1 = 6; goto ret;
set7:
tmp1 = 7; goto ret;
set8:
tmp1 = 8; goto ret;
set9:
tmp1 = 9; goto ret;
ret:
return tmp1;
fail:
sub_140001060("wrong input!\n");
exit(0);
}
이를 더욱더 간결하게 바꿔보면 아래와 같을 것이다.
저 위에 tmp2에서 0x65를 빼는 부분을 삭제하면 더욱 코드는 간결해질 것이다. 즉 이 함수는 어떤 문자 한글자를 받아 다음과 같은 규칙에 따라 숫자를 리턴하는 함수이다.
최종정리를 해보자면,, 사용자로부터 입력을 한 글자 받아 dwsqawdu와 합친 다음, sub_1400011E0을 호출한다.
사용자의 입력을 ?로 표시하고 각 글자를 변환시켜보면 아래와 같을 것이다. 이 값이 0x5b(91)와 같다면 correct!를 출력한다
위의 연산은 사칙연산 규칙을 따르지 않고 무조건 앞에서부터 연산하는 방식이기 때문에, 순서는 {(2*?-1)+2}*7이 91이다.
2*?+1=13. ?는 6이 된다!!!! 따라서 6을 입력하면 문 제 해 결
허우 이해 안되는 부분이 너무 많아서,,강의를 다시 봐야할거 같긴 하네요오오오오오오오오오오
드림핵 rev-basic-9
아직 아는게 별로 없지만,, 아니 하나도 없지만 뭐라도 해보면서 감을 익혀보려 합니다! 처음 풀 문제는 드림핵 사이트의 rev-basic-9 입니다.
https://dreamhack.io/wargame/challenges/23
문제 파일은 사용자에게 문자열을 입력받아 정해진 방법으로 입력값을 검증하여 Correct 또는 Wrong을 출력하는 프로그램입니다.
이때 Correct를 출력하는 입력값을 알아내면 되는 문제입니다!
프로그램 실행시 모습은 위와 같다. 혹시 엄청난 확률로 입력값이 Caputdraconis 이진 않을까 싶어 입력해봤지만 프로그램은 종료되었습니다.
IDA를 이용해 분석해보자!
허우 막상 키긴 했는디,, 허우 이게 뭐시여
많은 함수들 중 아까 프로그램 실행시 보았던 Input: 이라는 문자열을 출력하는 부분을 찾았습니다.
이 함수 내에서 문자열을 입력받고 결과 출력까지 모두 담당하는 것을 확인할 수 있었습니다.
쪼기 보면 sub_140001000(&v1)의 결과값이 true를 의미하는 1이라면 Correct!를 출력할 수 있을것으로 보입니다.
그럼 저 sub_1400001000 함수는 뭘 해주는 친구인지 확인해봅시다!
호오,,인자로 주어진 v1은 char형이였다. 문자열 포인터 str에 v1(a1)을 대입한다. v3에는 a1의 길이를 대입해준다.
이때 v3의 값을 이용한 조건문을 통해 0i64를 리턴하냐 아니면 strmp(~~)==0 의 결과를 리턴하냐인데, 0i64는 구글링을 해보니 0을 의미한다. 0은 False를 의미하고, 이는 이전에 보았던 함수에서 Wrong을 출력하게 될 것이다. 그렇기에 저 if문을 통과해야 합니다.
대충 살펴봐도 저 두 값이 동일해야함을 알 수 있고 이는 흐음 쪼끔 복잡하네,,
Suninatas Lev-9
http://suninatas.com/challenges/reversing
압축파일을 다운로드 받으면 Project1이라는 파일이 있다.
저 실행파일을 실행시킨 모습이다.
이를 IDA로 열고 저기 있는 Click 이라는 버튼을 찾기 시작했다
아니 함수가 개많아씨
모든 문자열들을 찾아봤다. Congratulation!이라는 문자열,,,, 아마 이 문제의 목표를 성공시키면 출력되는 문자열 같다
흐하 어렵다
test로 비교를 하긴 하는디,,,, 이게 뭔지 모르겠다
하어~~~~~
같이 스터디를 진행하시는 분이 알려주셨는데,, 그냥 값이 나와있다고,,, 그래서 보니깐
위 사진은 문제 프로그램의 main 함수 부분이다. 여기 보다보면 이상하게,,, 숫자가 문자열처리 되어있다. 그리고 이를 입력하니..
또이이이잉~?
913465가 인자로 들어가는 함수를 찾아보니
저렇게 넣는거보니깐...살짝 의심 +70
개어렵다. 그냥 913465 입력~