0. 학습목표
C 코드에서 만든 변수와 함수가 실행파일 안에서 어디에 놓이고, 실행 중 메모리에서 어떻게 보이는지 이해하기
1. 변수의 종류와 수명
| 분류 기준 | 예시 분류 | 무엇을 기준으로 보나 |
| 수명 기준 | automatic, static, allocated, thread | 메모리에 언제 생기고 언제 사라지는가? |
| 선언 위치 / 접근 범위 기준 | 지역 변수, 전역 변수 | 어디에서 접근할 수 있는가? |
| 저장 클래스 지정자 기준 | static, extern, auto, register | 선언에 어떤 키워드가 붙었는가? |
1-1. 수명기준 : 언제 생기고 언제 사라지는가
| 수명 기준 | 의미 | 예시 |
| automatic | 블록에 들어갈 때 생기고 블록을 나가면 사라짐 | 일반 지역 변수 |
| static | 프로그램 시작부터 종료까지 유지 | 전역 변수, static 변수 |
| allocated | malloc() 등으로 만들고 free()로 해제 | 동적 할당 메모리 |
| thread | 스레드 시작부터 종료까지 유지 | _Thread_local 변수 |
#include <stdlib.h>
int g; // static storage duration
void func(void) {
int a; // automatic storage duration
static int b; // static storage duration
int *p = malloc(sizeof(int));
// p 자체는 automatic
// malloc으로 확보한 메모리는 allocated
free(p);
}
1-2. 선언 위치 : 어디에서 사용할 수 있는가
| 선언 위치 / 접근 범위 기준 | 의미 | 예시 |
| 지역 변수 | 함수나 블록 {} 안에서만 사용 가능 | int a; |
| 전역 변수 | 함수 밖에서 선언되어 여러 함수에서 사용 가능 | int g; |
| 파일 범위 static 변수 | 같은 .c 파일 안에서만 사용 가능 | static int s; |
int g = 10; // 흔히 전역 변수라고 부름
static int s = 20; // 파일 범위 static 변수
void func(void) {
int a = 1; // 지역 변수
if (a > 0) {
int b = 2; // 이 블록 안에서만 사용 가능
}
}
※ static int s는 함수 밖에 있으므로 파일 범위 변수지만, static 때문에 다른 .c 파일에서는 직접 접근할 수 없습니다.
1-3. 저장 클래스 지정자 기준 : 어떤 키워드가 붙었는가
| 저장 클래스 지정자 | 의미 | 예시 |
| auto | 자동 저장 기간. 지역 변수의 기본값이라 거의 생략 | auto int a; |
| register | 자동 저장 기간. 주소 연산자 & 사용 제한 | register int r; |
| static | 정적 저장 기간 또는 내부 연결 | static int s; |
| extern | 다른 곳에 정의된 변수를 참조 | extern int g; |
| _Thread_local | 스레드별 저장 기간 | _Thread_local int t; |
extern int g; // 다른 파일 또는 다른 위치에 정의된 g를 참조
void func(void) {
auto int a = 1; // 보통 auto는 생략
register int r = 2; // 가능하면 레지스터 사용 힌트, 주소 사용 제한
static int count = 0; // 함수가 끝나도 값 유지
}
1-4. 정리
int g = 1; // ① 전역 변수, ② static 수명
static int fs = 2; // ① 파일 범위 static 변수, ② static 수명
void func(void) {
int a = 3; // ① 지역 변수, ② automatic 수명
static int b = 4; // ① 지역 변수, ② static 수명
int *p = malloc(sizeof(int));
// p는 지역 변수 + automatic 수명
// malloc 메모리는 allocated 수명
}
| 코드 | 선언 위치 기준 | 수명 기준 | 저장 클래스 지정자 기준 |
| int g | 전역 변수 | static | 생략 |
| static int fs | 파일 범위 변수 | static | static |
| int a | 지역 변수 | automatic | 생략, 사실상 auto |
| static int b | 지역 변수 | static | static |
| int *p | 지역 변수 | automatic | 생략 |
| malloc() 메모리 | 변수 선언 아님 | allocated | 저장 클래스 지정자 없음 |
2. 리버싱에서 보는 메모리 구조
| 영역 | 주로 들어가는 것 | 리버싱에서 의미 |
| .text / code | 함수 코드 | 어셈블리로 분석하는 핵심 영역 |
| .rdata | 읽기 전용 문자열, 상수 | 문자열 검색으로 기능 추정 |
| .data | 초기값 있는 전역변수 | 상태값, 설정값 추정 |
| .bss | 초기값 없는 전역변수 | 실행 중 채워지는 전역 상태 |
| stack | 지역변수, 함수 호출 정보 | 인자, 반환주소, 지역변수 추적 |
| heap | malloc()으로 잡은 메모리 | 동적 버퍼, 구조체, 객체 추적 |
※ 지역변수가 대체로 스택 프레임 안에서 관찰되기 때문에 “지역변수 = 스택에서 자주 보임”으로 이해 (무조건X)
2-1. (실습)메모리 주소 확인
파일명 : mem01.c
#include <stdio.h>
#include <stdlib.h>
int g_init = 10; // 초기값 있는 전역변수
int g_zero; // 초기값 없는 전역변수
void target_function(void) {
printf("target_function called\n");
}
void show_layout(void) {
int local_value = 123; // 지역변수
static int static_value = 456; // static 지역변수
char local_string[] = "STACK"; // 스택에 잡히는 문자 배열
char *literal = "READONLY"; // 문자열 리터럴 주소를 가리키는 포인터
int *heap_value = malloc(sizeof(int));
if (heap_value == NULL) {
printf("malloc failed\n");
return;
}
*heap_value = 789;
printf("function address : %p\n", (void *)target_function);
printf("global initialized : %p, value=%d\n", (void *)&g_init, g_init);
printf("global zero : %p, value=%d\n", (void *)&g_zero, g_zero);
printf("static local : %p, value=%d\n", (void *)&static_value, static_value);
printf("local int : %p, value=%d\n", (void *)&local_value, local_value);
printf("local char array : %p, value=%s\n", (void *)local_string, local_string);
printf("string literal pointer : %p, value=%s\n", (void *)literal, literal);
printf("heap memory : %p, value=%d\n", (void *)heap_value, *heap_value);
free(heap_value);
}
int main(void) {
show_layout();
return 0;
}

첫째, local_value와 local_string은 주소가 서로 가깝게 나올 가능성이 크다.
둘 다 함수 안의 지역 데이터라서 리버싱할 때 스택 프레임 안에서 자주 보임
둘째, g_init, g_zero, static_value는 지역변수와 다른 주소대에 있을 가능성이 크다.
이 값들은 실행 중 계속 유지되는 성격을 가집니다. 전역 수명은 프로그램 실행 전체 동안 존재
셋째, literal = "READONLY"의 문자열은 지역 배열이 아니다.
literal이라는 포인터 변수 자체는 지역변수지만, "READONLY" 문자열 데이터는 보통 읽기 전용 데이터 영역에서 관찰됨.
넷째, heap_value는 malloc(sizeof(int))로 확보한 동적 메모리이다.
sizeof는 해당 타입이나 객체 표현에 필요한 바이트 수를 돌려주는 연산자이며, sizeof가 객체 저장에 필요한 바이트수를 제공
3. 포인터
포인터는 “주소를 저장하는 변수”
int a = 10;
int *p = &a;
| 코드 | 의미 |
| int a = 10; | 정수 변수 a 생성 |
| &a | a의 주소 |
| int *p | 정수 주소를 담는 포인터 변수 |
| p = &a | p가 a를 가리킴 |
| *p | p가 가리키는 곳의 값 |
3-1. (실습)포인터 감 잡기
파일명 : ptr01.c
#include <stdio.h>
int main(void) {
int a = 10;
int b = 20;
int *p = &a;
printf("a address : %p\n", (void *)&a);
printf("b address : %p\n", (void *)&b);
printf("p value : %p\n", (void *)p);
printf("*p value : %d\n", *p);
p = &b;
printf("p value : %p\n", (void *)p);
printf("*p value : %d\n", *p);
*p = 999;
printf("b value : %d\n", b);
return 0;
}

※ *p = 999;는 포인터 변수 p의 값을 바꾸는 게 아니라, p가 가리키는 대상의 값을 바꾸는 코드
4. 배열과 포인터
📌학습 Point 📌
1. 배열은 같은 자료형 변수가 연속으로 배치된 구조이다.
2. 배열 이름은 많은 상황에서 첫 번째 원소의 주소처럼 사용된다.
3. arr[index]는 *(arr + index)와 같은 방식으로 이해할 수 있다.
4. arr와 &arr는 주소값은 같아 보여도 타입이 다르다.
5. 타입이 다르면 포인터 연산에서 이동 크기가 달라진다.
6. 문자열은 char 배열이며, 끝에 '\0'이 들어간다.
7. strlen과 sizeof는 보는 기준이 다르다.
4-1. 배열이란?
배열은 같은 자료형의 값을 여러 개 연속해서 저장하는 공간이다.
arr[0] = 10
arr[1] = 20
arr[2] = 30
| 주소 | 값 |
| 1000 | arr[0] = 10 |
| 1004 | arr[1] = 20 |
| 1008 | arr[2] = 30 |
★ 배열 원소들이 연속된 메모리 공간에 놓인다
4-2. 배열 이름과 첫 번째 원소 주소
배열은 포인터가 아니지만, 많은 상황에서 배열 이름은 첫 번째 원소의 주소처럼 사용된다.
| 표 | 의미 |
| arr | 배열 이름. 많은 상황에서 첫 번째 원소 주소처럼 사용 |
| &arr[0] | 첫 번째 원소 arr[0]의 주소 |
| &arr | 배열 전체의 주소 |
★ arr와 &arr는 출력되는 주소값은 같아 보일 수 있지만 타입이 다르다.
char a[] = "admin"; //문자 배열을 만들고, 그 안에 admin\0을 저장
char *p = "admin"; //문자열 리터럴 "admin"이 있는 주소를 p가 가리킴
★ 리버싱 Point
char a[] = "admin";은 함수 안에 있으면 스택 쪽에서 보일 가능성이 큼
char *p = "admin";의 "admin" 문자열 자체는 보통 실행파일의 읽기 전용 문자열 영역에서 보임
4-3. (실습)배열과 포인터 주소 비교
파일명 : arr01.c
#include <stdio.h>
int main(void) {
int arr[3] = {10, 20, 30};
int *p = arr;
printf("arr : %p\n", (void *)arr); //대부분의 식에서 &arr[0]처럼 동작
printf("&arr[0] : %p\n", (void *)&arr[0]); //첫 번째 원소의 주소
printf("&arr : %p\n", (void *)&arr); //배열 전체의 주소
printf("arr[0] : %d\n", arr[0]);
printf("*p : %d\n", *p);
printf("arr[1] : %d\n", arr[1]); //두 번째 원소
printf("*(p + 1) : %d\n", *(p + 1)); //p에서 int 1개만큼 이동한 위치의 값
printf("arr[2] : %d\n", arr[2]);
printf("*(p + 2) : %d\n", *(p + 2));
printf("sizeof(arr): %zu\n", sizeof(arr)); //p에서 int 1개만큼 이동한 위치의 값
printf("sizeof(p) : %zu\n", sizeof(p)); //포인터 변수 1개의 크기
return 0;
}

① arr / &arr[0]
배열 이름 arr은 많은 상황에서 첫 번째 원소 주소처럼 사용된다.
② arr[0] / *p
int *p = arr;
이 코드는 p가 배열의 첫 번째 원소를 가리키게 선언.
=> arr[0] = *p
③ sizeof(arr) / sizeof(p)
- arr은 int 3개짜리 배열 = (int 1개) x 3 = 4바이트 x 3 = 12바이트
- p는 배열이 아니라 포인터 변수
64비트 프로그램에서는 포인터 크기가 보통 8바이트
32비트 프로그램에서는 보통 4바이트
| 표현 | 의미 |
| sizeof(arr) | 배열 전체 크기 |
| sizeof(p) | 포인터 변수 하나의 크기 |
4-4. (실습)arr + 1과 &arr + 1 차이
파일명 : arr02.c
#include <stdio.h>
int main(void) {
int arr[3] = {10, 20, 30};
printf("arr : %p\n", (void *)arr);
printf("arr + 1 : %p\n", (void *)(arr + 1));
printf("&arr : %p\n", (void *)&arr);
printf("&arr + 1 : %p\n", (void *)(&arr + 1));
return 0;
}

| 표현 | 의미 | 타입 관점 |
| arr | 첫 번째 원소의 주소처럼 사용됨 | int *처럼 동작 |
| &arr | 배열 전체의 주소 | int (*)[3] |
주소 숫자는 둘 다 '0061FF14' 로 같지만, 가리키는 단위가 다르다.
arr = 첫 번째칸(0번)의 시작 주소
&arr = 배열 전체 묶음의 시작 주소
시작점은 같지만, 포인터 연산할 때 이동 단위가 달라진다.
arr + 1 : int 하나의 크기 만큼 이동 = 4바이트 이동
=> arr + 1 == &arr[1]
&arr + 1 : &arr이 가리키는 배열 전체 만큼 이동 = 4바이트 X 3 = 12바이트 이동
★ 리버싱 Point
base + index * 4
이런 형태를 보면 보통 int 배열[index] 접근일 가능성이 있다.
4-5. 문자열과 null 문자
char s1[] = "admin";
이 코드는 실제로 아래와 같은 배열을 만듭니다.
'a' 'd' 'm' 'i' 'n' '\0'
즉, "admin"은 글자 5개처럼 보이지만, 실제 배열에는 마지막에 문자열 끝을 표시하는 '\0'이 들어간다.
4-6. (실습) 문자열과 null 문자 확인
파일명 : str01.c
#include <stdio.h>
#include <string.h>
int main(void) {
char s1[] = "admin";
printf("s1 as string : %s\n", s1);
printf("strlen(s1) : %zu\n", strlen(s1));
printf("sizeof(s1) : %zu\n", sizeof(s1));
printf("s1[0] as char : %c\n", s1[0]);
printf("s1[1] as char : %c\n", s1[1]);
printf("s1[2] as char : %c\n", s1[2]);
printf("s1[3] as char : %c\n", s1[3]);
printf("s1[4] as char : %c\n", s1[4]);
printf("s1[5] as int : %d\n", s1[5]);
return 0;
}

결과에서 중요한 부분은
sizeof(s1) = 6
strlen(s1) = 5
왜냐하면 "admin"은 실제 메모리에서 아래처럼 저장되기 때문이다.
'a' 'd' 'm' 'i' 'n' '\0'
sizeof(s1)은 배열 전체 크기라서 6
strlen(s1)은 null 문자 전까지의 문자 개수라서 5.
strlen은 null-terminated byte string에서 첫 null 문자 전까지의 문자 수를 반환
★ 리버싱 Point
char password[] = "admin";
| 관찰되는 것 | 추론 |
| "admin" 문자열 | 비밀번호, 명령어, 설정값일 수 있음 |
| strcmp, strncmp 호출 | 문자열 비교 가능성 |
| strlen 호출 | 입력 길이 검사 가능성 |
| mov, lea로 주소 전달 | 문자열 주소를 함수 인자로 넘기는 흐름 가능성 |
| 반복문으로 한 글자씩 비교 | 직접 구현한 문자열 비교 가능성 |
특히 이런코드가 있으면:
if (strcmp(input, "admin") == 0) {
printf("OK\n");
}
리버싱에서는 보통 "admin", "OK" 같은 문자열을 먼저 찾고, 그 문자열을 참조하는 함수를 따라간다.
4-7. (실습) 문자열과 null 문자 확인
파일명 : str02.c
#include <stdio.h>
#include <string.h>
int main(void) {
char s1[] = "admin";
for (int i = 0; i < sizeof(s1); i++) {
printf("s1[%d] = char:%c, int:%d\n", i, s1[i], s1[i]);
}
return 0;
}

마지막 s1[5]는 문자로 출력하면 눈에 잘 안 보인다. 하지만 정수로 보면 0
★ 0이 문자열의 끝
4-8. (실습) 중간에 '\0' 넣기
파일명 : str03.c
#include <stdio.h>
#include <string.h>
int main(void) {
char s1[] = "admin";
printf("before string : %s\n", s1);
printf("before strlen : %zu\n", strlen(s1));
printf("before sizeof : %zu\n", sizeof(s1));
s1[2] = '\0'; // 'm'대신 '\0' 넣기
printf("after string : %s\n", s1);
printf("after strlen : %zu\n", strlen(s1));
printf("after sizeof : %zu\n", sizeof(s1));
for (int i = 0; i < sizeof(s1); i++) {
printf("s1[%d] = int:%d\n", i, s1[i]);
}
return 0;
}

4-9. (실습) char s1[]과 char *s2 차이
파일명 : str04.c
#include <stdio.h>
#include <string.h>
int main(void) {
char s1[] = "admin";
char *s2 = "admin";
printf("s1 address : %p\n", (void *)s1);
printf("&s1[0] : %p\n", (void *)&s1[0]);
printf("s2 value : %p\n", (void *)s2);
printf("s1 string : %s\n", s1);
printf("s2 string : %s\n", s2);
printf("sizeof(s1) : %zu\n", sizeof(s1));
printf("sizeof(s2) : %zu\n", sizeof(s2));
s1[0] = 'A';
printf("modified s1 : %s\n", s1);
return 0;
}

char s1[] = "admin";
이 코드는 배열을 만듭니다.
s1 배열 안에 'a' 'd' 'm' 'i' 'n' '\0' 저장
char *s2 = "admin";
이 코드는 s2라는 포인터 변수를 만들고, 문자열 리터럴 "admin"의 주소를 저장
=> sizeof(s2) = 포인터 변수 크기
| 코드 | 의미 | 수정 가능 여부 |
| char s1[] = "admin"; | 배열 안에 문자열 복사 | s1[0] = 'A' 가능 |
| char *s2 = "admin"; | 문자열 리터럴 주소를 가리킴 | s2[0] = 'A' 하면 위험 |
5. 함수 호출과 반환값
📌학습 Point 📌
1. 함수는 입력값을 받아서 작업한 뒤 결과를 돌려줄 수 있다.
2. 함수에 전달되는 값은 매개변수로 들어간다.
3. return은 현재 함수를 끝내고 값을 호출한 쪽으로 돌려준다.
4. 함수 안의 지역변수는 함수가 끝나면 더 이상 사용할 수 없다.
5. 일반 변수는 값이 복사되어 전달된다.
6. 포인터를 넘기면 함수 안에서 원본 값을 바꿀 수 있다.
★ 리버싱 포인트
- 리버싱에서 함수는 매우 중요한 분석 단위이다.
- 실행파일을 분석할 때는 보통 함수 단위로 코드를 나누어 본다.
- 함수 호출은 어셈블리에서 call 명령어로 보이고, 함수 종료는 ret 명령어로 보인다.
- 함수의 인자, 지역변수, 반환값을 추적하면 프로그램이 어떤 값을 입력받고 어떤 결과를 내는지 파악할 수 있다.
5-1. 함수 기본 구조
int add(int a, int b) {
return a + b;
}
| 부분 | 의미 |
| int | 반환값의 자료형 |
| add | 함수 이름 |
| int a, int b | 매개변수 |
| return a + b; | 계산 결과를 호출한 곳으로 반환 |
★ 리버싱 포인트
리버싱할 때 함수 이름이 항상 보이는 것은 아니다.
디버그 정보가 없는 실행파일에서는 add 같은 함수명이 보이지 않고,
FUN_401000, sub_140001000 같은 이름으로 보일 수 있다.
그래서 함수 이름보다 중요한 것은 함수의 동작이다.
예를 들어 어떤 함수가 인자 2개를 받아 더한 뒤 결과를 반환한다면,
함수 이름을 몰라도 “두 값을 계산해서 반환하는 함수”라고 해석할 수 있다.
5-2. (실습) 함수 인자와 return 값 확인
파일명 : func01.c
#include <stdio.h>
int add(int a, int b) {
int result = a + b;
printf("[add] a = %d\n", a);
printf("[add] b = %d\n", b);
printf("[add] result = %d\n", result);
return result;
}
int main(void) {
int x = 10;
int y = 20;
printf("[main] before function call\n");
int z = add(x, y);
printf("[main] after function call\n");
printf("[main] z = %d\n", z);
return 0;
}

return result;
return은 두 가지 일을 한다.
1. 현재 함수 실행을 끝낸다.
2. 값을 호출한 쪽으로 돌려준다.
★ 리버싱 포인트
이 실습에서 리버싱 관점으로 볼 핵심은 “함수 호출 흐름”이다.
main 함수에서 add 함수를 호출한다.
add 함수는 인자 2개를 받고, 계산한 결과를 return 한다.
main 함수는 add 함수의 반환값을 z에 저장한다.
나중에 어셈블리로 보면 이 흐름은 대략 다음 구조로 보인다.
1. 인자 준비
2. call add
3. add 함수 내부 실행
4. return
5. 반환값 사용
리버싱에서는 call 명령어를 보면
“여기서 다른 함수로 흐름이 이동한다”라고 먼저 생각하면 된다.
리버싱에서 return 값은 매우 중요하다.
많은 검사 함수는 성공/실패를 return 값으로 알려준다.
예를 들어 다음과 같은 함수가 있다고 하면,
check_password() → 성공하면 1, 실패하면 0
check_license() → 정상 라이선스면 1, 아니면 0
is_admin() → 관리자면 1, 아니면 0
호출한 쪽에서는 이 반환값을 조건문으로 검사한다.
따라서 리버싱할 때는
“이 함수가 무엇을 반환하는가?”
“반환값이 어디에서 사용되는가?”
를 따라가는 것이 중요하다.
5-3. (실습) return 이후 코드는 실행되지 않는다
파일명 : func02.c
#include <stdio.h>
int check_number(int n) {
printf("[check_number] start\n");
if (n > 0) {
printf("[check_number] positive number\n");
return 1;
printf("[check_number] after return 1\n");
}
printf("[check_number] zero or negative number\n");
return 0;
printf("[check_number] after return 0\n");
}
int main(void) {
int result1 = check_number(10);
printf("[main] result1 = %d\n\n", result1);
int result2 = check_number(-5);
printf("[main] result2 = %d\n", result2);
return 0;
}

printf("[check_number] after return 1\n");
printf("[check_number] after return 0\n");
이유는 return을 만나면 함수가 즉시 끝나기 때문이다.
★ 리버싱 포인트
이 실습은 “조건에 따라 함수가 중간에 끝날 수 있다”는 것을 보여준다.
리버싱에서는 이런 함수를 자주 본다.
예를 들어 검사 함수가 있다고 할 때,
조건이 맞으면 return 1
조건이 틀리면 return 0
이런 구조가 많이 나온다.
특히 return 지점이 여러 개 있는 함수는
성공 경로와 실패 경로가 나뉘어 있을 가능성이 있다.
그래서 리버싱할 때는 함수 안에서 return이 몇 군데 있는지,
각 return 값이 무엇인지 확인하는 것이 좋다.
5-4. (실습) 값 전달: 함수 안에서 바꿔도 원본은 안 바뀐다
파일명 : func03.c
#include <stdio.h>
void change_value(int n) {
printf("[change_value] before n = %d\n", n);
n = 999;
printf("[change_value] after n = %d\n", n);
}
int main(void) {
int x = 10;
printf("[main] before x = %d\n", x);
change_value(x);
printf("[main] after x = %d\n", x);
return 0;
}

change_value 함수 안에서 n은 999로 바뀌었다.
하지만 main의 x는 여전히 10이다.
change_value(x);
에서 x 자체가 넘어간 것이 아니라, x의 값인 10이 n으로 복사되어 들어간 것으로 이해하면 된다.
★ 리버싱 포인트
이 실습은 “값이 복사되어 전달된다”는 것을 보여준다.
리버싱에서 함수 인자를 볼 때 중요한 질문은 이것이다.
이 함수가 원본 데이터를 바꾸는 함수인가?
아니면 값을 받아서 내부에서만 사용하는 함수인가?
일반 값이 인자로 전달되면 함수 안에서 값을 바꿔도 원본은 바뀌지 않는다.
나중에 어셈블리에서 보면,
함수 안에서 어떤 지역변수 값이 바뀌더라도
그것이 호출한 쪽의 원본 변수와 직접 연결되어 있지 않을 수 있다.
따라서 값을 바꾸는 코드가 보인다고 해서
항상 원본 데이터가 바뀐다고 보면 안 된다.
5-5. (실습) 포인터 전달: 함수 안에서 원본 바꾸기
파일명 : func04.c
#include <stdio.h>
void change_value_by_pointer(int *p) {
printf("[change_value_by_pointer] p = %p\n", (void *)p);
printf("[change_value_by_pointer] *p before = %d\n", *p);
*p = 999;
printf("[change_value_by_pointer] *p after = %d\n", *p);
}
int main(void) {
int x = 10;
printf("[main] &x = %p\n", (void *)&x);
printf("[main] before x = %d\n", x);
change_value_by_pointer(&x);
printf("[main] after x = %d\n", x);
return 0;
}

change_value_by_pointer(&x); //x의 주소를 함수에 넘김
*p = 999; //p가 가리키는 곳, 즉 x에 999를 저장하는 코드
따라서 main으로 돌아온 뒤에도 x = 999
| 방식 | 호출 코드 | 함수 안에서 원본 변경 가능? |
| 값 전달 | change_value(x); | 불가능 |
| 주소 전달 | change_value_by_pointer(&x); | 가능 |
★ 리버싱 포인트
이 실습은 리버싱에서 매우 중요하다.
포인터가 인자로 전달되면, 함수는 호출한 쪽의 원본 메모리를 직접 수정할 수 있다.
리버싱할 때 어떤 함수가 포인터를 인자로 받고,
그 포인터가 가리키는 위치에 값을 쓰고 있다면
그 함수는 “데이터를 변경하는 함수”일 가능성이 높다.
예를 들어 다음과 같은 동작을 의심할 수 있다.
1. 버퍼에 문자열을 채우는 함수
2. 구조체 값을 수정하는 함수
3. 결과값을 포인터로 돌려주는 함수
4. 암호화/복호화 결과를 메모리에 쓰는 함수
5. 설정값이나 상태값을 바꾸는 함수
따라서 리버싱할 때는 단순히 함수 반환값만 보면 안 되고, 함수가 인자로 받은 주소에 어떤 값을 쓰는지도 봐야 한다.
★ 리버싱 포인트
리버싱에서는 함수 인자를 볼 때 다음 두 가지를 구분해야 한다.
1. 값 자체가 전달되는가?
2. 주소가 전달되는가?
값이 전달되면 함수 안에서 계산만 하고 끝날 가능성이 있다.
주소가 전달되면 함수 안에서 원본 메모리를 읽거나 쓸 수 있다.
특히 포인터 인자에 대해 쓰기 동작이 있으면 중요하다.
예를 들어,
*p = 999;
같은 코드는 단순 계산이 아니라 “특정 메모리 위치에 값을 저장하는 동작”이다.
나중에 어셈블리에서는 이런 동작이 메모리 쓰기 명령으로 보인다.
5-6. (실습) 포인터 전달: 함수 안에서 원본 바꾸기
파일명 : pass01_debug.c
#include <stdio.h>
#include <string.h>
int check_password(const char *input) {
int result = strcmp(input, "admin123");
printf("[check_password] input = %s\n", input);
printf("[check_password] strcmp result = %d\n", result);
if (result == 0) {
printf("[check_password] password matched\n");
return 1;
}
printf("[check_password] password not matched\n");
return 0;
}
int main(void) {
char input[32];
printf("password: ");
if (scanf("%31s", input) != 1) {
printf("[main] scanf failed\n");
return 1;
}
int check_result = check_password(input);
printf("[main] check_password result = %d\n", check_result);
if (check_result) {
printf("success\n");
} else {
printf("fail\n");
}
return 0;
}


strcmp()의 결과는 두 문자열이 같으면 0이다. 다른 문자열일 때는 0이 아닌 값이 나오며, 그 정확한 숫자는 입력값과 구현에 따라 달라질 수 있으므로 이 실습에서는 0인지 아닌지만 보면 된다. strcmp가 두 문자열의 관계를 나타내는 값을 반환한다고 설명한다.
★ 리버싱 포인트
이 실습은 나중에 정적 분석과 동적 분석으로 그대로 이어지는 중요한 패턴이다.
프로그램은 사용자의 입력값을 받고, strcmp 함수로 기준 문자열과 비교한다.
strcmp는 두 문자열이 같으면 0을 반환한다.
그 결과가 0이면 check_password 함수는 1을 반환하고, 다르면 0을 반환한다.
main 함수는 check_password의 반환값을 보고 success 또는 fail을 출력한다.
리버싱에서 이 구조는 다음처럼 볼 수 있다.
입력값 저장
↓
문자열 비교 함수 호출
↓
비교 결과 확인
↓
성공/실패 값 반환
↓
반환값에 따라 분기
따라서 문자열 비교 함수, 반환값 검사, 조건 분기는 리버싱에서 반드시 집중해서 봐야 하는 부분이다.
6. 구조체와 메모리 배치
📌학습 Point 📌
1. 구조체는 여러 자료형을 하나로 묶는 사용자 정의 자료형이다.
2. 구조체 멤버는 선언한 순서대로 배치된다.
3. 구조체 안에는 padding이 들어갈 수 있다.
4. sizeof(struct)는 멤버 크기의 단순 합보다 클 수 있다.
5. 구조체 변수는 . 연산자로 멤버에 접근한다.
6. 구조체 포인터는 -> 연산자로 멤버에 접근한다.
7. 구조체 주소와 첫 번째 멤버 주소는 보통 같다.
★ 리버싱 포인트
리버싱에서 구조체는 매우 중요하다.
프로그램은 여러 값을 따로따로 관리하기보다 관련 있는 값들을 구조체로 묶어서 관리하는 경우가 많다.
예를 들어 다음과 같은 데이터가 구조체로 표현될 수 있다.
1. 사용자 계정 정보
2. 설정값
3. 네트워크 패킷
4. 파일 헤더
5. 악성코드 설정 데이터
6. 암호화/복호화 상태값
7. 프로그램 내부 상태 정보
리버싱할 때 특정 주소를 기준으로
+0, +4, +8, +16 같은 방식으로 여러 값을 읽고 쓰는 코드가 보이면 구조체 멤버 접근일 가능성을 의심할 수 있다.
6-1. 구조체 기본 구조
구조체는 서로 다른 자료형을 하나로 묶을 때 사용한다.
struct User {
int id;
char name[16];
int score;
};
이 구조체는 다음 정보를 하나로 묶는다.
| 멤버 | 자료형 | 의미 |
| id | int | 사용자 번호 |
| name | char[16] | 이름 |
| score | int | 점수 |
구조체 변수를 만들면 이렇게 사용한다.
struct User user1;
user1.id = 1;
user1.score = 90;
문자열 배열에는 strcpy()를 사용한다.
strcpy(user1.name, "hong");
6-2. 구조체 멤버 값 확인
파일명 : struct01.c
#include <stdio.h>
#include <string.h>
struct User {
int id;
char name[16];
int score;
};
int main(void) {
struct User user1;
user1.id = 1;
strcpy(user1.name, "hong");
user1.score = 90;
printf("user1.id = %d\n", user1.id);
printf("user1.name = %s\n", user1.name);
printf("user1.score = %d\n", user1.score);
return 0;
}

★ 리버싱 포인트
이 실습은 구조체가 관련 있는 값을 하나로 묶는다는 것을 보여준다.
리버싱에서는 변수명이 보이지 않을 수 있다.
그러면 user1.id, user1.name, user1.score 같은 이름도 보이지 않는다.
대신 특정 기준 주소에서 여러 위치를 접근하는 형태로 보일 수 있다.
예를 들어 개념적으로는 다음처럼 볼 수 있다.
기준 주소 + 0 → id
기준 주소 + 4 → name
기준 주소 + 20 → score
즉, 구조체는 “기준 주소 + 오프셋”으로 멤버에 접근한다고 이해하면 된다.
6-3. 구조체 멤버 주소 확인
파일명 : struct02.c
#include <stdio.h>
#include <string.h>
struct User {
int id;
char name[16];
int score;
};
int main(void) {
struct User user1;
user1.id = 1;
strcpy(user1.name, "hong");
user1.score = 90;
printf("&user1 = %p\n", (void *)&user1);
printf("&user1.id = %p\n", (void *)&user1.id);
printf("user1.name = %p\n", (void *)user1.name);
printf("&user1.score = %p\n", (void *)&user1.score);
printf("sizeof(user1) = %zu\n", sizeof(user1));
return 0;
}

일반적인 환경에서 int가 4바이트라면 다음처럼 배치될 수 있습니다.
주소 기준 멤버
+0 id
+4 name[0]
+5 name[1]
...
+19 name[15]
+20 score
크기를 계산하면:
id = 4바이트
name = 16바이트
score = 4바이트
총합 = 4 + 16 + 4 = 24바이트
★ 리버싱 포인트
구조체 멤버는 기준 주소에서 일정한 offset으로 접근된다.
예를 들어 user1 주소가 0061FF00이라면,
0061FF00 + 0 → id
0061FF00 + 4 → name
0061FF00 + 20 → score
처럼 해석할 수 있다.
리버싱에서 [기준주소 + 0x00], [기준주소 + 0x04], [기준주소 + 0x14] 같은 접근이 반복되면
하나의 구조체를 다루고 있을 가능성이 있다.
6-4. 구조체 padding 개념
구조체 크기는 항상 멤버 크기의 단순 합과 같지는 않는다.
컴파일러는 접근 효율이나 정렬 조건 때문에 구조체 멤버 사이에 빈 공간을 넣을 수 있다.
이 빈 공간을 보통 padding이라고 한다.
6-5. (실습) padding 확인
파일명 : struct03.c
#include <stdio.h>
struct Test1 {
char a;
int b;
};
struct Test2 {
int b;
char a;
};
struct Test3 {
char a;
char c;
int b;
};
int main(void) {
struct Test1 t1;
struct Test2 t2;
struct Test3 t3;
printf("sizeof(struct Test1) = %zu\n", sizeof(struct Test1));
printf("sizeof(struct Test2) = %zu\n", sizeof(struct Test2));
printf("sizeof(struct Test3) = %zu\n", sizeof(struct Test3));
printf("\n");
printf("&t1 = %p\n", (void *)&t1);
printf("&t1.a = %p\n", (void *)&t1.a);
printf("&t1.b = %p\n", (void *)&t1.b);
printf("\n");
printf("&t2 = %p\n", (void *)&t2);
printf("&t2.b = %p\n", (void *)&t2.b);
printf("&t2.a = %p\n", (void *)&t2.a);
printf("\n");
printf("&t3 = %p\n", (void *)&t3);
printf("&t3.a = %p\n", (void *)&t3.a);
printf("&t3.c = %p\n", (void *)&t3.c);
printf("&t3.b = %p\n", (void *)&t3.b);
return 0;
}

1) struct Test1
struct Test1 {
char a;
int b;
};
+0 a 1바이트
+1 padding
+2 padding
+3 padding
+4 b 4바이트
총 8바이트
2) struct Test2
struct Test2 {
int b;
char a;
};
배치는 보통 이렇게 된다.
+0 b 4바이트
+4 a 1바이트
+5 padding
+6 padding
+7 padding
총 8바이트
3) struct Test3
struct Test3 {
char a;
char c;
int b;
};
배치는 보통 이렇게 된다.
+0 a 1바이트
+1 c 1바이트
+2 padding
+3 padding
+4 b 4바이트
총 8바이트
★ 리버싱 포인트
구조체 padding 때문에 멤버가 딱 붙어서 배치되지 않을 수 있다.
리버싱에서 구조체를 추정할 때
+0, +1, +4처럼 중간에 건너뛰는 offset이 보일 수 있다.
이때 비어 있는 부분은 실제 데이터가 아니라
정렬을 위한 padding일 가능성이 있다.
따라서 구조체를 분석할 때는 단순히 멤버 크기를 더하는 것이 아니라 offset과 정렬까지 함께 봐야 한다.
6-6. 구조체 포인터와 -> 연산자
구조체 변수 자체에 접근할 때는 .을 쓴다.
구조체 포인터를 통해 접근할 때는 ->를 쓴다.
아래 두 코드는 같은 의미이다.
p->id
(*p).id
p->id는 (*p).id를 보기 쉽게 줄여 쓴 표현이다.
6-7. (실습) 구조체 포인터
파일명 : struct04.c\
#include <stdio.h>
#include <string.h>
struct User {
int id;
char name[16];
int score;
};
void print_user(struct User *p) {
printf("[print_user] p = %p\n", (void *)p);
printf("[print_user] id = %d\n", p->id);
printf("[print_user] name = %s\n", p->name);
printf("[print_user] score = %d\n", p->score);
}
void update_score(struct User *p, int new_score) {
p->score = new_score;
}
int main(void) {
struct User user1;
user1.id = 1;
strcpy(user1.name, "hong");
user1.score = 90;
printf("[main] &user1 = %p\n", (void *)&user1);
print_user(&user1);
update_score(&user1, 100);
printf("[main] after update_score\n");
print_user(&user1);
return 0;
}

print_user(&user1);
이 코드는 user1 구조체의 주소를 함수에 넘긴다. 그래서 print_user() 안의 p는 user1을 가리킨다.
printf("[print_user] id = %d\n", p->id);
이 코드는 p가 가리키는 구조체의 id 멤버를 읽는다.
update_score(&user1, 100);
이 코드는 user1 주소와 새 점수 100을 넘긴다.
함수 안에서:
p->score = new_score;
를 실행하면 user1.score가 실제로 바뀐다.
★ 리버싱 포인트
구조체 포인터는 리버싱에서 매우 자주 보인다.
함수 인자로 구조체 주소가 전달되면 함수 안에서 해당 구조체의 여러 멤버를 읽거나 쓸 수 있다.
예를 들어 p->score = new_score 같은 코드는
구조체 기준 주소에서 score 멤버 위치에 값을 쓰는 동작이다.
리버싱에서는 이런 코드가
[구조체 기준 주소 + 특정 offset]에 값을 저장하는 형태로 보일 수 있다.
따라서 어떤 함수가 포인터 인자를 받고,
그 포인터 기준으로 여러 offset을 접근하면
구조체를 다루는 함수일 가능성이 높다.
7. 동적 할당과 heap
📌학습 Point 📌
1. malloc은 실행 중 필요한 만큼 메모리를 요청한다.
2. malloc이 성공하면 할당된 메모리의 시작 주소를 반환한다.
3. malloc으로 받은 주소는 포인터 변수에 저장해서 사용한다.
4. malloc으로 확보한 메모리는 직접 free로 해제해야 한다.
5. free 이후의 포인터를 계속 사용하는 것은 위험하다.
6. heap 메모리는 지역변수처럼 함수가 끝난다고 자동으로 사라지는 구조가 아니다.
7. 구조체도 malloc으로 heap에 만들 수 있다.
malloc()은 <stdlib.h>에 정의된 함수이고, 지정한 바이트 수만큼 초기화되지 않은 저장공간을 할당한다.
할당에 성공하면 그 메모리를 가리키는 포인터를 반환한다.
C의 동적 메모리 관리 함수에는 malloc, calloc, realloc, free 등이 있고, free는 이전에 할당된 메모리를 해제하는 함수이다.
★ 리버싱 포인트
리버싱에서 heap은 매우 중요하다.
프로그램이 실행 중에 문자열, 구조체, 버퍼, 복호화 결과, 네트워크 데이터 등을 동적으로 만들 때 heap을 사용할 수 있다.
특히 malloc, calloc, realloc 같은 함수 호출이 보이면
“여기서 실행 중 사용할 메모리 공간을 새로 확보한다”라고 생각하면 된다.
반대로 free 호출이 보이면
“이 메모리 공간은 더 이상 사용하지 않으려는 지점”으로 볼 수 있다.
7-1. stack 변수와 heap 변수 차이
int a = 10;
이런 지역변수는 보통 함수 안의 stack 쪽에서 관찰된다.
int *p = malloc(sizeof(int));
이 코드는 실행 중에 동적으로 int 하나를 저장할 공간을 요청한다.
| 구분 | 예시 | 특징 |
| stack 지역변수 | int a = 10; | 함수 안에서 자동으로 관리 |
| heap 동적 메모리 | malloc(sizeof(int)) | 직접 요청하고 직접 해제 |
C 언어 기준으로는 malloc이 할당한 공간은 free 등으로 해제하기 전까지 유지되는 동적 저장공간으로 이해하면 된다.
자동 저장 기간의 객체는 블록에 들어갈 때 할당되고 블록을 벗어나면 해제된다.
7-2. (실습) malloc으로 int 하나 만들기
파일명 : heap01.c
#include <stdio.h>
#include <stdlib.h>
int main(void) {
int local_value = 10; // 이 변수는 main() 함수 안의 지역변수
int *heap_value = malloc(sizeof(int)); //int 하나를 저장할 공간을 동적으로 요청
if (heap_value == NULL) {
printf("malloc failed\n");
return 1;
}
*heap_value = 100; //heap_value가 가리키는 메모리 공간에 100을 저장
printf("&local_value = %p\n", (void *)&local_value);
printf("heap_value = %p\n", (void *)heap_value);
printf("local_value = %d\n", local_value);
printf("*heap_value = %d\n", *heap_value);
free(heap_value); //malloc으로 확보했던 메모리를 해제
return 0;
}

heap_value = heap 메모리 주소
*heap_value = 그 주소에 들어 있는 값
★ 리버싱 포인트
malloc 호출 뒤에는 보통 반환된 주소를 어딘가에 저장한다.
그 뒤에 해당 주소를 기준으로 값을 쓰거나 읽는 코드가 이어진다.
리버싱할 때 malloc이 보이면 다음 흐름을 보면 좋다.
1. 몇 바이트를 요청하는가?
2. 반환된 포인터가 어디에 저장되는가?
3. 그 포인터로 어떤 값을 쓰는가?
4. 언제 free 되는가?
특히 malloc 직후에 문자열 복사, 구조체 초기화, 복호화 루프가 이어지면
그 heap 영역에 중요한 데이터가 만들어질 수 있다.
7-3. (실습) malloc으로 배열 만들기
파일명 : heap02.c
ㄴ int 여러 개를 저장하는 공간을 heap에 만들어 보기
#include <stdio.h>
#include <stdlib.h>
int main(void) {
int count = 5;
int *arr = malloc(sizeof(int) * count);
if (arr == NULL) {
printf("malloc failed\n");
return 1;
}
for (int i = 0; i < count; i++) {
arr[i] = (i + 1) * 10;
}
printf("arr address = %p\n", (void *)arr);
for (int i = 0; i < count; i++) {
printf("arr[%d] = %d, address = %p\n", i, arr[i], (void *)&arr[i]);
}
free(arr);
return 0;
}

int *arr = malloc(sizeof(int) * count);
count가 5이고 int가 4바이트라면:
sizeof(int) * count
= 4 * 5
= 20바이트
즉, int 5개를 저장할 수 있는 공간을 heap에 만든 것이다.
arr[i] = (i + 1) * 10;
이 코드는 배열처럼 값을 저장합니다. 하지만 중요한 차이가 있다.
int arr[5]; → stack 쪽 배열
malloc(sizeof(int) * 5) → heap 쪽 배열
둘 다 arr[i]처럼 접근할 수 있지만, 메모리 확보 방식이 다르다.
★ 리버싱 포인트
malloc으로 큰 크기를 요청한 뒤 반복문으로 값을 채우면
heap 버퍼를 초기화하는 코드일 가능성이 있다.
리버싱에서 다음 패턴을 보면 heap 배열 또는 heap 버퍼를 의심할 수 있다.
1. malloc(size) 호출
2. 반환된 주소 저장
3. 반복문 실행
4. 기준 주소 + index * 크기 형태로 값 저장
예를 들어 int 배열이면 index * 4 형태가 나올 수 있다.
char 버퍼이면 index * 1 형태로 한 바이트씩 접근할 수 있다.
7-3. (실습) malloc으로 문자열 만들기
파일명 : heap03.c
ㄴ 문자열 저장 공간을 heap에 만들어보기
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
int main(void) {
char *msg = malloc(16);
if (msg == NULL) {
printf("malloc failed\n");
return 1;
}
strcpy(msg, "hello");
printf("msg address = %p\n", (void *)msg);
printf("msg string = %s\n", msg);
printf("strlen(msg) = %zu\n", strlen(msg));
for (int i = 0; i < 16; i++) {
printf("msg[%d] = int:%d\n", i, msg[i]);
}
free(msg);
return 0;
}

※ malloc()으로 받은 메모리는 초기화되지 않은 저장공간이므로 strcpy()로 채운 "hello\0" 이후의 나머지 바이트 값은 환경에 따라 달라질 수 있다.
char *msg = malloc(16);
이 코드는 16바이트짜리 문자열 저장 공간을 heap에 만든다.
strcpy(msg, "hello");
이 코드는 heap 메모리에 문자열을 복사한다.
메모리 개념:
msg[0] = 'h'
msg[1] = 'e'
msg[2] = 'l'
msg[3] = 'l'
msg[4] = 'o'
msg[5] = '\0'
그래서:
strlen(msg) = 5
하지만 malloc(16)으로 16바이트를 확보했기 때문에 전체 공간은 16바이트이다.
★ 리버싱 포인트
heap에 문자열이 만들어지는 경우는 리버싱에서 자주 중요하다.
실행파일 안에 문자열이 평문으로 들어 있지 않고,
프로그램 실행 중에 heap에 만들어질 수도 있다.
예를 들어 다음과 같은 흐름이 가능하다.
1. malloc으로 문자열 버퍼 확보
2. 반복문으로 한 글자씩 값 저장
3. XOR 같은 연산으로 문자열 복원
4. 복원된 문자열을 API 호출이나 비교에 사용
따라서 정적 문자열 검색으로 보이지 않는 문자열도
실행 중 heap에 만들어질 수 있다는 점을 기억해야 한다.
7-4. free 이후 포인터 주의
free(p)는 p가 가리키는 메모리를 해제한다.
하지만 free(p)를 했다고 해서 포인터 변수 p 자체가 자동으로 NULL이 되는 것은 아니다.
예를 들어:
int *p = malloc(sizeof(int));
*p = 100;
free(p);
여기까지 실행하면 p가 가리키던 메모리는 해제된다.
그런데 아래 코드는 하지 말아야 한다.
printf("%d\n", *p);
이미 해제된 메모리를 다시 사용하는 형태가 된다. 안전하게는 free() 후에 이렇게 처리한다.
free(p);
p = NULL;
파일명 : heap04.c
#include <stdio.h>
#include <stdlib.h>
int main(void) {
int *p = malloc(sizeof(int));
if (p == NULL) {
printf("malloc failed\n");
return 1;
}
*p = 100;
printf("before free p = %p\n", (void *)p);
printf("before free *p = %d\n", *p);
free(p);
p = NULL;
printf("after free p = %p\n", (void *)p);
if (p == NULL) {
printf("p is NULL, do not use it\n");
}
return 0;
}

free(p);
이 코드는 heap 메모리를 해제한다.
p = NULL;
이 코드는 포인터 변수 p에 더 이상 유효한 주소가 없다는 뜻을 명확히 표시한다.
if (p == NULL) {
printf("p is NULL, do not use it\n");
}
로 실수로 포인터를 사용하는 것을 줄일 수 있다.
★ 리버싱 포인트
free 이후 같은 포인터를 다시 사용하는 코드는 취약점 분석에서 중요한 단서가 될 수 있다.
리버싱에서 다음 흐름은 주의해서 봐야 한다.
1. malloc으로 메모리 확보
2. 포인터 저장
3. free로 해제
4. 같은 포인터를 다시 읽거나 씀
이런 흐름은 use-after-free 같은 메모리 안전성 문제와 관련될 수 있다.
이번 단계에서는 공격 방법을 다루지 않고,
“해제된 메모리를 다시 쓰면 위험하다”는 개념만 이해하면 된다.
7-6. (실습) 구조체를 heap에 만들기
파일명 : heap05.c
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
struct User {
int id;
char name[16];
int score;
};
int main(void) {
struct User *user = malloc(sizeof(struct User));
if (user == NULL) {
printf("malloc failed\n");
return 1;
}
user->id = 1;
strcpy(user->name, "hong");
user->score = 90;
printf("user address = %p\n", (void *)user);
printf("&user->id = %p\n", (void *)&user->id);
printf("user->name = %p\n", (void *)user->name);
printf("&user->score = %p\n", (void *)&user->score);
printf("user->id = %d\n", user->id);
printf("user->name = %s\n", user->name);
printf("user->score = %d\n", user->score);
free(user);
user = NULL;
return 0;
}

struct User *user = malloc(sizeof(struct User));
이 코드는 struct User 하나를 저장할 수 있는 크기만큼 heap 메모리를 확보한다.
user->id = 1;
strcpy(user->name, "hong");
user->score = 90;
구조체 포인터를 통해 heap에 있는 구조체 멤버를 채운다.
구조체가 이전 단원과 같다면 배치는 개념적으로 다음과 같다.
user + 0 → id
user + 4 → name
user + 20 → score
★ 리버싱 포인트
리버싱에서 malloc(sizeof(struct ...)) 같은 패턴은 매우 중요하다.
함수 이름이나 구조체 이름이 보이지 않아도,
일정 크기의 heap 메모리를 확보한 뒤
그 기준 주소에서 여러 offset에 값을 쓰면
구조체를 heap에 만들고 초기화하는 코드일 수 있다.
예를 들어 다음과 같은 접근이 반복되면 구조체를 의심할 수 있다.
base + 0x00
base + 0x04
base + 0x14
이런 offset은 구조체 멤버 위치일 가능성이 있다.