2021. 5. 10. 22:11ㆍLayer7/Layer7_Reverse Engineering
포인터, 주소를 저장하는 변수
포인터는 변수의 주소를 저장하는 역할을 수행한다. 우리가 프로그램에서 사용하는 변수들은 모두 메모리 상에 저장되게 되는데, 이때 메모리는 기나 긴 도로라고 생각하고, 각 집의 번지수를 메모리 주소라고 비유할 수 있다.
위와 같이 메모리에 변수가 담기게 되는데, 각 변수의 시작 주소를 주소값이라 한다. 즉, 위에서 변수는 0x11, 0x12, 0x13까지 공간을 차지함에도 불구하고 변수의 주소값은 항상 데이터가 시작되는 부분이다. (0x10).
포인터는 이러한 주소값을 저장하기 위한 자료형이다. 다만, 이는 또한 주소값을 저장하는 것이기에 포인터 또한 변수이다.
주소 연산자(&)와 역참조 연산자(*)의 등장
주소 연산자와 역참조 연산자는 포인터를 사용하기 위한 필수적인 연산자이다. 이를 이용하여 포인터가 변수의 주소를 저장하게 함으로써 가리키게 할 수 있고, 변수 내의 값까지 사용할 수 있게 된다.
1) 주소 연산자
주소 연산자는 AND로 착각하기 쉽지만, AND와 달리 주소 연산자는 단항 연산자이며, 다음과 같이 변수 이름 앞에 바로 붙는다.
&variable
이 주소 연산자 &(Ampersand)를 붙임으로써 메모리 상의 주소값을 얻을 수 있다.
variable의 값 출력
printf("%d", variable);
variable의 주소 출력
printf("0x%x", &variable);
2) 역참조 연산자
포인터의 값을 출력하면 포인터가 저장한 주소밖에 얻지 못하지만, 포인터 변수에 역참조 연산을 수행하면 가리키고 있는 변수의 값까지 출력할 수 있게 된다. 다시 말해, 역참조 연산자는 포인터 변수가 저장한 주소값에 있는 변수의 값을 가리킨다.
즉,
int variable = 10;
int* pVariable = &variable;
역참조 연산을 수행하려면 해당 포인터 앞에 *(Asterisk)를 붙임으로써 포인터가 가리키고 있는 변수의 값을 얻을 수 있다.
*pVariable = variable = 10
int num = 10;
int pNum = #
라 할 때 우리가 num 변수에 담긴 값을 두 가지 방법으로 출력할 수 있다.
printf("%d", num); 과 같이 직접적으로 num을 출력할 수도 있지만,
printf("%d", *pNum); 처럼 포인터 변수에 담긴 주소값에 접근하여 그 주소에 있는 값을 출력(역참조 연산)하는 간접적인 방법도 있다.
num은 지역변수이므로 ({} 블럭 안에만 사용 가능한 변수, 가용성은 한 함수 내로 제한된다)
printf("%d", num);은 변수가 원래 선언되어 있던 함수로 사용이 제한되어 다른 함수에서는 같은 변수에 접근할 수 없다.
그러나
printf("%d", *pNum);은 포인터를 이용하여 프로그램 내에서 어디서나, 항상 같은 변수의 값을 출력할 수 있다.
즉, 전역변수를 이용하지 않고 주소값을 통해 지역변수에 어디서나 접근할 수 있게 하는 것이 포인터의 주된 사용 목적 중 하나이다.
num1의 값 출력
printf("%d", num1);
printf("%d", *ptr1);
num1의 주소 출력
printf("0x%x", &num1);
printf("0x%x", ptr1);
위와 같이 값은 변수의 값을 얻으려면 변수를 출력(num1)할 수도 있고, 변수의 주소를 담는 포인터에 역참조 연산을 수행(*ptr1)해도 같은 결과를 얻을 수 있다.
주소를 출력하려면 변수에 주소 연산(&num)을 수행할 수도 있고 변수의 주소를 담는 포인터의 값(ptr)을 출력해도 똑같이 주소값을 얻을 수 있다.
num1 = *ptr1 = num1의 값
&num1 = ptr1 = num1의 주소값
역참조 연산을 이용하면 출력뿐만 아니라 변수의 값도 바꿀 수 있다. 즉, num1 = 10과 *ptr = 10의 결과는 같다. ptr에 역참조 연산을 함으로써 ptr이 저장한 주소에 있는 변수의 값을 변경하는 것이기 때문이다.
포인터의 선언
어떤 형태의 변수의 주소값을 저장할 것인지에 따라 포인터도 선언할 때 자료형을 지정해 주어야 한다. 자료형 별로 변수의 크기가 다르기 때문에 역참조 연산을 수행할 때 시작 주소로부터 총 몇 바이트까지가 데이터인지 지정하기 위해서 필요한 것이다.
그러나 포인터 변수의 크기는 자료형과는 상관없이 항상 일정하다. 32비트 운영체제에서는 4바이트, 64비트 운영체제에서는 포인터의 기본 크기는 8바이트이다.
포인터 자료형은 int 포인터, char 포인터, double 포인터 등이 있다.
선언할 때는 주소를 저장할 변수의 자료형과 일치한 포인터를 사용해야 하며, 포인터임을 알리기 위해 *를 붙여야 한다.
int* pVariable1;
char* pVariable2;
가리킬 변수의 자료형을 닮아서 int형 변수의 주소를 저장하려면 int 포인터, char형 변수의 주소를 저장하려면 char형 포인터를 선언한다. 선언할 때에는 가리키는 변수의 자료형 뒤에 *를 붙여 포인터임을 알려준다.
포인터로 연산을...?
포인터가 담는 주소값으로 연산도 수행할 수 있다. 다만, 포인터로 연산을 시도하려고 하는 순간 컴파일러가 항의를 하기 시작한다.
곱셈, 나눗셈 등의 연산은 어디에도 쓰이지 않아 무의미하며, 오류도 발생한다.
그러나 덧셈과 뺄셈을 할 시 오류가 나지 않는다. 덧셈은 포인터끼리 하는 것은 의미가 없다. 그러나 포인터의 값에 정수값을 더하면 포인터가 가리키는 자료형의 크기만큼 증가한다.
4바이트 공간을 차지하는 int형 변수 num1을 가리키는 ptr_num1에 4를 더하면 가리키는 자료형 * 더하는 값, 4 * 4 만큼 증가하므로 총 16바이트만큼 증가한다. (0x62fe0c + 0x10 = 0x62fe1c)
그리고 1바이트 공간을 차지하는 char형 변수 ch1을 가리키는 ptr_ch1에 4를 더하면 1 * 4 만큼 증가하여 총 4바이트만큼 증가한다. (0x62fe0b + 0x4 = 0x62fe0f)
뺄셈의 경우에는 두 개의 주소값의 차이를 알아낼 수 있는데, 이는 변수 사이의 거리를 알려주므로 유용하게 쓰이며, 오류도 발생하지 않는다.
인수 전달 방식: Call by Value, Call by Reference
인수는 프로그램 내에서 함수를 호출할 때 함수의 입력값이 되는 것이다. 호출 함수에서 피호출 함수에게 주는 입력은 인수 또는 인자라고 하고, 피호출 함수 쪽에서 호출 함수가 넘겨주는 값을 받아 저장하기 위한 변수를 매개변수라고 한다.
즉, 간단히 두 수의 합을 계산하는 함수를 예로 들면,
main 함수 내에서 sum(2,3)라는 명령으로 sum을 호출할 때 main에서 주는 2와 3은 인수,
그리고 sum 쪽에서 void sum(int a, int b)와 같이 main에서 주는 인수를 받기 위한 변수 a와 b를 매개변수라 한다.
이와 같이 변수를 인수로 주면 값만 넘겨줄 뿐, 실제 변수를 넘겨주는 것은 아니다. 이를 Call by Value 방식이라 한다.
1) Call by Value 방식
값에 의한 호출이라는 뜻으로, 인자의 값을 복사하여 함수 내에서 사용하는 방식이다. 따라서 원래 사용되던 변수와 복사된 새로운 변수 사이에 아무런 관계가 없고, 새로운 변수의 값을 바꾼다고 해서 원래 사용되던 변수에 영향을 끼치지도 않는다.
2) Call by Reference 방식
참조에 의한 호출이라는 뜻으로, 인수로 변수의 값을 주는 것이 아니라 변수의 주소값을 주고, 함수 쪽에서는 포인터 변수로 인수로 받는 주소값을 저장한다는 방식이다. 이는 값을 복사하는 것이 아니라 메모리 상에서의 주소를 넘겨주고, 다른 함수 내에서 역참조 연산을 이용하여 값을 직접 바꿀 수 있도록 한다.
즉, 값이 전달되는 것이 아닌 변수 자체가 공유되므로 원래 변수에 영향을 줄 수 있다.
포인터는 이와 같이 함수 간의 Call by Reference 방식의 인수 전달을 위해 주로 사용된다.
사실은 Call by Reference의 진짜 의미는 서로 다른 함수에서 같은 변수를 같은 이름으로 사용하는 것이다. 그러나 C언어에서는 Call by Reference를 구현할 방법이 존재하지 않는다. 포인터조차도 주소의 값을 주고받아서 같은 변수에 접근하는 것이기에 Call by Value 방식의 특징도 함께 갖고 있다.
다중 포인터
원래 포인터의 역할은 한 변수의 주소를 저장하는 것이었다. 그러나 포인터도 변수이고 메모리 상에서 저장되어 있기 때문에 포인터 또한 주소값을 갖는다. 한 포인터의 주소값을 저장하는 포인터를 이중 포인터, 이중 포인터의 주소값을 저장하는 포인터를 삼중 포인터, 삼중 포인터를 저장하는 포인터는 사중 포인터라고 한다. 끝없이 많은 포인터를 저장할 수 있지만 그만큼 머리가 아파지는 거.
int num1 = 10;
int* p = &num1; > 단일 포인터
int**pp = &p; > 이중 포인터
int***ppp = &pp; > 삼중 포인터
int****pppp = &ppp; > 사중 포인터
현재 포인터들은 이러한 구조를 가지고 있다.
p에 역참조 연산을 수행하면 num1의 값인 10을 얻을 수 있다.
그러면 pp에 역참조 연산을 하면 어떻게 될까?
가리키고 있는 p의 값인 0x10을 얻게 된다. pp를 그대로 출력하면 가리키고 있는 p의 주소인 0x20을 얻게 된다.
pp를 이용하여 num1의 값을 출력하려면 역참조 연산을 두 번 하면 된다. **pp의 의미는 *pp에 한번 더 역참조 연산을 수행한다는 것이다. 즉, *pp인 0x10에 다시 한번 연산을 함으로써 0x10에 위치해 있는 num1 변수의 값을 얻을 수 있게 된다.
ppp에는 0x30이 담겨 있고 이를 그대로 출력하면 0x30, *을 한번 붙이면 0x20, **을 붙이면 0x10, ***을 붙이면 10이 출력된다. &ppp를 할 시에는 ppp가 메모리에서 위치해 있는 주소인 0x40을 출력한다.
즉, 위와 같은 구조에서는 num1의 값을 5가지 방법으로 출력할 수 있다.
num1 = *p = **pp = ***ppp = ****pppp
num1의 주소를 얻고자 하면
&num1 = p = *pp = **ppp = ***pppp를 생각할 수 있다.
끝까지 역참조 연산을 하지 않고 p 포인터의 값을 얻기 위한 것이기 때문에 역참조 연산을 num1의 값을 얻는 것보다 한번 적게 수행하는 것이다.
메인 사진 출처: Unsplash
© 남찬우, 2021
'Layer7 > Layer7_Reverse Engineering' 카테고리의 다른 글
리버싱 실습 (0) | 2021.05.27 |
---|---|
어셈블리 동적 분석 실습 (0) | 2021.05.24 |
어셈블리 - Assembly x64 (0) | 2021.05.18 |
Pwndbg 명령어 (0) | 2021.05.16 |
실행파일이 만들어지는 과정 (0) | 2021.05.13 |