이번 페이지 요약 : 

코딩도장 C언어 Unit 39, 40

1. 문자열 사용하기

2. 입력값 문자열에 저장 

 

Unit 39. 문자열 사용하기

c언어 문자를 저장하는 자료형은 있지만 문자열을 저장하는 자료형은 없다. 그래서 char 자료형에 문자열을 저장하면 컴파일은 되지만 실행은 되지 않는다. 컴파일을 한 뒤 디버깅 모드로 보면 다음과 같은 에러가 발생한다.

39.1 문자와 문자열 포인터 알아보기

문자열은 다음과 같이 char 포인터 형식으로 사용한다.

1
2
3
4
5
6
7
8
9
10
11
#include <stdio.h>
 
int main()
{
    char c1 = 'a';
    char *s1 = "Hello";
 
    printf("%c\n", c1);
    printf("%s\n", s1);
    return 0;
}
cs

문자는 글자가 하나만 있는 상태이고, 문자열은 글자 여러 개가 계속 이어진 상태를 의미한다. 문자는 1바이트 크기라 char에 저장할 수있지만, 문자열은 1바이트가 넘어서므로 char에 저장할 수 없다. 따라서 문자열은 변수에 직접 저장하지 않고 포인터를 이용하여 저장한다. 포인터는 메모리 주소를 저장하기 때문에 메모리 주소를 보고 문자열이 저장된 위치로 이동한다. 문자열 리터럴이 있는 메모리 주소는 읽기 전용이기 때문에 다른 문자열을 덮어 쓸 수 없다. 문자열 리터럴이 저장되는 위치는 컴파일러가 결정한다.

문자열은 ""(큰따옴표)로 묶어야 하며 문자열을 출력할 때는 서식지정자 %s를 사용해야 한다.

C에서 문자열은 마지막 끝에 항상 널문자가 붙는다. NULL은 0으로 표현할 수 있으며 문자열의 끝을 나타낸다.

문자열을 출력할때는 문자들을 순서대로 계속 출력하다가 NULL을 만나면 출력을 끝낸다.

39.2 문자열 포인터에서 인덱스로 문자에 접근하기

문자열도 다음과 같이 대괄호를 사용하여 인덱스로 접근할 수 있다.

1
2
3
4
5
6
7
8
9
10
11
#include <stdio.h>
 
int main()
{
    char *s1 = "Hello";
 
    printf("%c\n", s1[1]);
    printf("%c\n", s1[4]);
    printf("%c\n", s1[5]);
    return 0;
}
cs

문자 하나를 출력할 때는 %c 서식지정자를 사용하며 맨 뒤 문자열인 5번째 인덱스를 가져오면 NULL 이기 때문에 출력해도 화면에 표시되지 않는다.

문자열 포인터에서 인덱스로 문자를 가져오는 것은 가능하지만 인덱스로 문자를 할당하는것은 불가능하다. 문자를 할당하려고 에러가 발생한다. 문자열 리터럴이 있는 메모리 주소는 읽기 전용이기 때문이다.

38.3 배열 형태로 문자열 선언하기

문자열은 다음과 같이 문자 배열에 저장할 수 도 있다.

1
2
3
4
5
6
7
8
9
#include <stdio.h>
 
int main()
{
    char s1[10= "Hello";
    
    printf("%s\n", s1);
    return 0;
}
cs

위 코드는 크기가 10인 배열을 선언한 뒤 문자열을 할당한 것이다.

문자열에 배열을 저장하는 방식은 배열 요소 하나하나에 문자가 저장된다. 배열이기 때문에 인덱스는 0부터 시작하고, 문자열의 맨 뒤에 NULL이 들어간다. 남는 공간에도 모두 NULL이 들어간다.

배열로 문자열을 사용할 때는 무조건 배열을 선언한 즉시 문자열로 초기화해야 한다. 이미 선언한 배열에는 문자열을 할당할 수 없으며 정 할당하고 싶으면 요소에 문자 하나 하나를 넣으면 된다.

배열을 선언할 때는 배열의 크기를 할당할 문자열보다 크게 지정해야 한다. 문자열 문자 개수보다 배열의 크기가 작다면 컴파일은 되지만 문자열이 제대로 출력되지 않는다. 문자열은 마지막에 NULL이 꼭 들어가야 하므로 문자 개수에 하나 더한 크기가 문자열을 저장할 수 있는 배열의 최소 크기 이다.

다음과 같이 문자열을 바로 할당할 때는 배열의 크기를 생략할 수 있다.

1
2
3
4
5
6
7
8
9
#include <stdio.h>
 
int main()
{
    char s1[] = "Hello";
    
    printf("%s\n", s1);
    return 0;
}
cs

이와 같은 경우 배열의 크기는 문자열의 문자 개수에 맞게 자동으로 지정된다. 위 경우 문자 5개에 NULL을 더해 6이 된다.

39.4 배열 형태의 문자열에서 인덱스로 문자에 접근하기

다음과 같이 인덱스로 접근할 수 있다.

1
2
3
4
5
6
7
8
9
10
11
#include <stdio.h>
 
int main()
{
    char s1[] = "Hello";
    
    printf("%c\n", s1[1]);
    printf("%c\n", s1[4]);
    printf("%c\n", s1[5]);
    return 0;
}
cs

배열을 인덱스로 접근하면 char과 같기 때문에 %c로 출력할 수 있다. 배열로 저장된 문자열 맨 뒤에는 NULL이 있어 출력값에는 보이지 않는다.

배열 형태의 문자열은 다음과 같이 인덱스로 접근하여 문자를 할당할 수 있다.

1
2
3
4
5
6
7
8
9
10
11
#include <stdio.h>
 
int main()
{
    char s1[] = "Hello";
    
    s1[0= 'A';
    
    printf("%s\n", s1);
    return 0;
}
cs

Hello 가 들어있는 문자 배열의 0번 인덱스에 A를 할당해서 Aello가 되었다. 배열 형태의 문자열은 배열에 문자열이 모두 복사되었기 때문에 인덱스로 접근하여 내용을 변경할 수 있다.

39.5 퀴즈

정답은 b,e,g,h이다.

정답은 d 이다.

정답은 b 이다.

정답은 a, d 이다.

39.6 연습문제 : 문자열 만들기

정답은 char s1[] 이다.

39.7 연습문제 : 문자열 요소 출력

정답은 s1[10] 이다.

39.8 심사문제 : 문자열 만들기

정답은 다음 코드와 같다.

char s1[] = "Beethoven\n9th\nSymphony";

출력에 줄바꿈이 있으므로 개행문자 \n을 사용했다.

Unit 40. 입력 값을 문자열에 저장하기

40.1 입력 값을 배열 형태의 문자열에 저장하기

scanf 함수에서 서식지정자 %s를 사용하면 입력값을 배열 형태의 문자열에 저장할 수 있다.

1
2
3
4
5
6
7
8
9
10
11
12
#include <stdio.h>
 
int main()
{
    char s1[10];
 
    printf("문자열을 입력하세요 : ");
    scanf("%s", s1);
 
    printf("%s\n", s1);
    return 0;
}
cs

scanf 함수에 서식지정자로 %s를 넣어서 문자열을 입력받을 수 있다. 매개변수는 입력값을 저장할 배열을 넣는데, 배열 앞에는 일반 변수와 달리 &를 붙이지 않는다. 위 코드에서는 크기가 10인 배열을 선언했으므로 문자열 맨 뒤에 붙는 널문자 까지 포함하여 총 10개의 문자를 저장할 수 있다. 따라서 실제 저장할 수 있는 문자는 9개이다.

scanf에서 %s로 문자열을 저장할 때 입력된 문자열에 공백이 있다면 배열에는 공백 직전까지만 저장된다. 공백을 포함하여 저장하고 싶다면 서식지정자로 %[^\n]s를 사용하면 된다.

40.2 입력 값을 문자열 포인터에 저장하기

문자열 포인터를 선언한 뒤 scanf 함수로 입력 값을 문자열 포인터에 저장하고 실행하면 에러가 발생한다. 이미 할당된 문자열 포인터에 저장된 메모리 주소는 읽기만 할 수 있고, 쓰기가 막혀있기 때문이다.

입력값을 문자열 포인터에 저장하려면 문자열이 들어갈 공간을 따로 만들어야 한다. malloc 함수로 메모리를 할당한 뒤 문자열을 저장하면 된다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
#include <stdio.h>
#include <stdlib.h>
 
int main()
{
    char *s1 = malloc(sizeof(char* 10);
    
    printf("문자열을 입력하세요 : ");
    scanf("%s", s1);
 
    printf("%s\n", s1);
    free(s1);
    return 0;
}
cs

char 10개 크기 만큼 동적 메모리를 할당하여 입력값을 문자열 포인터 s1에 저장할 수 있다. 입력 받을 때 s1은 포인터이므로 &를 붙이지 않는다. 문자열 사용이 끝나면 free 함수로 메모리를 해제해야 한다. 문자열 중간에 공백이 있다면 문자열 포인터에는 공백 직전까지만 저장된다.

40.3 문자열을 여러 개 입력받기

다음과 같이 공백으로 구분된 문자열 여러개를 입력받을 수 있다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
#include <stdio.h>
 
int main()
{
    char s1[10];
    char s2[10];
 
    printf("문자열을 두 개 입력하세요 : ");
    scanf("%s %s", s1, s2);
 
    printf("%s\n", s1);
    printf("%s\n", s2);
    return 0;
}
cs

문자열 두 개를 저장하기 위한 공간을 선언하고, scanf 함수에서 서식지정자로 %s를 두 개 넣어준다. 문자열이 저장될 배열을 두 개 넣어주면 공백으로 구분된 문자열을 입력받을 수 있다. 문자열을 더 입력받고 싶으면 %s와 입력받을 배열(또는 문자열 포인터)의 개수를 늘려주면 된다.

40.4 퀴즈

정답은 c, e, g 이다.

정답은 9 이다.

40.5 연습문제 : 입력받은 문자열을 배열에 저장하기

정답은 scanf("%s", s1); 이다.

40.6 연습문제 : 입력받은 문자열을 동적 메모리에 저장하기

정답은 다음 코드와 같다.

1. char *s1 = malloc(sizeof(char) * 10);

2. scanf("%s", s1);

40.7 연습문제 : 문자열 세 개 입력받기

정답은 scanf("%s %s %s", s1, s2, s3); 이다.

40.8 심사문제 : 문자열 4 개 입력받기

정답은 다음 코드와 같다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
#include <stdio.h>
 
int main()
{
    char s1[30];
    char s2[30];
    char s3[30];
    char s4[30];
    
    scanf("%s %s %s %s", s1, s2, s3, s4);
    
    printf("%s\n%s\n%s\n%s\n", s1, s2, s3, s4);
    return 0;
}
cs

해당 글의 원문은 아래 주소에서 확인할 수 있다.

bpsecblog.wordpress.com/2016/05/20/gdb_memory_3/

 

(完)우리집에 GDB 있는데… 메모리 보고갈래?(3)

우리집에 GDB 있는데… 메모리 보고 갈래?(3) Day #3. 이쯤 했으면 이제 그만 엔터치자 오늘부터 1일♡ 1) Debugging  (IDA, GDB 사용) 우리는 이미 tomato.c 소스가 있지만, 보통 소스는 잘 안주잖아여 없다

bpsecblog.wordpress.com

소스 코드가 없을 경우 IDA의 헥스레이 기능을 활용하면 다음과 같이 바이너리를 디컴파일 하여 원본 코드와 흡사한 소스를 보여준다. f5 단축키를 이용하여 사용할 수 있다. (헥스레이 기능이 매우 편리할 것 같아서 조만간 IDA나 헥스레이 기능이 있는 무료 툴인 기드라를 설치해야 겠다.)

IDA에서 8번째 라인을 보면 strcpy((char *) &v4, argv[1]); 코드에서 버퍼오버플로우 취약점이 있다. argv[1]을 v4 변수에 복사할 때 버퍼 크기를 검증하는 코드가 없기 때문이다. 변수 v4와 v5는 지역변수 이기 때문에 스택에 할당되고, length가 검증되지 않은 v4에 argv[1]의 값이 복사될 때 v4는 할당받은 공간 그 이상을 사용하게 되고, v5가 v4보다 높은 주소에 할당되어 있다면 v5에 원하는 값을 삽입할 수 있다. strcpy는 버퍼오버플로우 취약점이 발생할 가능성이 있기 때문에 strcpy_s로 대체하여 사용하는것을 권고하고 있다.

main 함수의 어셈 코드는 다음과 같다.

strcpy 함수의 주소는 0x08048515이므로 이 위치에 breakpoint를 걸고 strcpy가 실행되기 전후의 스택을 확인할 것이다.

0xbffff022는 esp가 가리키는 값 즉, v4이고, 0xbffff2d7은 esp+0x4가 가리키는 값 즉 argv[1]이다. strcpy 호출 전에는 argv[1]에만 값이 들어 있다가 strcpy로 v4에도 값이 들어간 것을 확인할 수 있다.

다음은 main의 어셈코드 일부이다. 

cmp는 비교하는 명령으로 첫번째 값에서 두번째 값을 뺄 때 마이너스가 되면 CF=1, 0이면 ZF=1 플래그가 설정된다. 플래그는 CPU의 플래그 레지스터에 저장되는 처리 데이터 이다. 플래그의 설정에 따라 분기문 조건이 다르다. 아래 jne의 경우 jump not equal로 비교 결과가 다르면 main+108 주소로 점프한다. 이 주소로 점프할 경우 system 함수를 실행하지 않는다. 따라서 ebp-0xc의 값이 0x1 이여야 system 함수를 실행하는 것이다.

현재 스택의 구조는 다음과 같다.

v4의 값이 흘러 넘치면 v5에도 영향을 미칠수 있다.

v4의 범위가 벗어나서 이후 메모리에 42(B)가 들어간 것을 확인할 수 있다.

v4의 값을 흘러넘치게 해서 v5가 0x00000001이 되면 셸을 띄울 수 있을것이다.
B로 채워진 부분들이 0x00000001이 되면 조건문은 일치시켜서 셸을 띄울 수 있을 것 같다.

다음과 같이 넣으면 gdb로 확인했을 때 다음과 같다.

메모리에 들어가는 값을 보면 0x00000001이 아닌 다른 값이 들어가는 것을 확인할 수 있다. 문자열 00000001이 들어가는 것이다. hex값을 메모리에 넣으려면 다음과 같이 python 이나 perl 같은 스크립트 언어를 사용하여 넣어야 한다.

python -c 옵션을 사용하면 command 라인에서 출력할 수 있고, 커맨드의 출력값은 `(백틱)을 이용해서 넘길 수 있다. tomato 파일의 인자로 백틱으로 감싼 파이썬 명령을 넣으면 v5 변수에 0x00000001을 덮어쓸 수 있다.

파이썬 명령어가 tomato 파일의 인자로 들어가서 v4 변수를 덮어쓰고 v5 도 0x00000001로 채워서 셸이 따지는 것을 확인할 수 있다.

 

해당 글의 원문은 아래 주소에서 확인할 수 있다.

bpsecblog.wordpress.com/2016/04/04/gdb_memory_2/

 

gdb를 사용하기 위해 다음의 코드를 컴파일하여 사용한다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
//tomato.c
#include <string.h>
#include <stdio.h>
 
void func2() {
    puts("func2()");
}
 
void sum(int a, int b) {
    printf("sum : %d\n", a+b);
    func2();
}
 
int main(int argc, char *argv[]) {
 
    int num=0;
    char arr[10];
 
    sum(1,2);
    strcpy(arr,argv[1]);
    printf("arr: %s\n", arr);
    if(num==1){
        system("/bin/sh");
    }
    return 0;
}
cs

이 소스코드에서 sum함수는 코드 내부에서 a + b의 결과값을 출력해주는 함수를 새로 만든 것이고, strcpy() 함수는 문자열을 다른 배열이나 포인터(메모리)로 복사하는 함수이다. system함수는 인수로 실행시킬 프로세스의 이름을 받아 그 프로세스를 실행하는 것이다.

 

이 소스코드는 다음 명령으로 컴파일 한다.

gcc -fno-stack-protector -o tomato tomato.c

-fno-stack-protector 옵션을 사용하는 이유는  gcc는 스택을 보호하기 위해 'canary' 라는 것을 삽입하여 함수 내에서 사용하는 스택 프레임과 return address 사이에 canary를 넣는다. 만약 Buffer Overflow가 발생하여 canary를 덮으면 이를 감지하고 프로그램을 강제 종료 한다. 이 과정을 SSP(Stack Smashing Protection)이라고 한다. 해당 옵션을 사용라게 되면 오버플로우가 발생해도 프로그램이 강제 종료 되지 않도록 보호기법을 해제한 것이다.

반대로 모든 프로시저에 이 보호기법을 적용하려면 -fstack-protector-all 옵션을 사용하면 된다.

 

gdb는 오픈소스로 공개되어 있는 무료 디버거로 코드에서 어떤 라인을 실행할 때 어떤 값이 어떤 메모리 주소에 올라가는지 과정을 보여주는 것이다. 

다음과 같은 명령어로 gdb로 프로그램을 실행할 수 있다.

gdb ./tomato

gdb로 tomato를 실행하면 다음과 같은 화면이 나온다.

보기 편하도록 어셈코드를 인텔 형식으로 설정하려면 다음 명령어를 사용한다.

set disassembly-flavor intel

at&t 형식으로 보려면 intel 자리에 att를 넣으면 된다.

disas main으로 main함수의 어셈블리 코드를 확인하면 다음과 같다.

b는 breakpoint를 거는 명령으로 b *[메모리주소]의 형태로 사용한다. 메모리 주소에는 메모리 주소, 함수의 이름, 이를 기준으로 한 offset <+0> 으로 걸어도 된다.

모두 같은 주소에 breakpoint가 걸림을 확인할 수 있다.

 

info b 명령으로 breakpoint 정보를 확인할 수 있다.

breakpoint가 중복되니 삭제한다.

삭제할때는 d (breakpoint 번호) 의 형태로 삭제한다.

프로그램을 실행할 때는 run 명령을 사용하는데 뒤에 매개변수를 넣을 수 있다. 

위와 같은 실행은 터미널에서 ./tomato aaaaaaaaaa로 실행한 것과 동일하다.

main 함수의 시작 위치에 breakpoint를 걸었기 때문에 main 함수를 확인해 보면  => 화살표로 main에 멈춰있는 것을 확인할 수 있다.

ni 명령을 통해 인스트럭션을 한줄 한줄 실행할 수 있다.

ni를 몇번 더 해서 원문과 비슷한 위치에 eip가 멈추도록 했다.

현재 멈춘곳 이전의 인스트럭션을 보면 esp 값을 ebp 값에 저장하고 있다. esp, ebp는 스택에 관련된 레지스터 이다.

 

그 메모리에 어떤 값이 담겨있는지 확인하기 전에 메모리 출력 방식에 대해 정리하겠다.

몇바이트 만큼, 몇 진법으로 출력할 지 옵션을 주어 출력할 수 있다.

해당 메모리 주소의 값을 각각 1바이트, 2바이트, 4바이트 만큼 출력한 것이다. x/ 뒤에 b는 1바이트, h는 2바이트, w는 4바이트를 의미한다.

위는 해당 메모리 주소 값을 각각 16진수 10진수로 출력한 것이다. 

다음과 같이 두 바이트 옵션과 진법 옵션을 같이 사용할 수 있다.

위를 보면 4바이트 출력한 것과 1바이트씩 4개를 출력한 것이 순서가 반대이다. Intel CPU는 바이트를 배열할 때 거꾸로 써서 예를 들어 0x12345678을 저장한다고 하면 0x78563412처럼 거꾸로 저장하는 것이며 이를 Little Endian 이라 지칭한다.

gdb는 디버깅시 보기 편하게 하기 위해 Big Endian 형식으로 0x12345678와 같이 출력하기 때문에 1바이트씩 출력할 때와 4바이트로 출력할 때 달리보인다.

 

특정 레지스터를 기준으로 메모리 값을 보고 싶으면 다음과 같이 $register_name로 출력할 수 있다.

info reg 또는 i r 명령을 사용하면 레지스터 정보를 출력할 수 있다.

 

스택과 관련있는 레지스터의 종류는 다음과 같다.

  • EBP : 스택의 가장 밑바닥 주소를 가진 레지스터
  • ESP : 스택의 가장 상단 주소를 가진 레지스터
  • EIP : 현재 실행중인 명령의 주소를 가진 레지스터

현재 EIP의 상황은 화살표가 가리키고 있고, 그 전에 실행된 push ebp 와 mov ebp, esp라는 두 줄의 어셈 코드가 의미하는 것은 스택(프레임)이 생성된다는 것이다. 저 두 줄의 의미는 함수가 실행이 될때 그 이전의 ebp(sfp)를 스택에 push 하고, 현재 esp를 ebp에 저장하라(새로운 ebp 생성)는 뜻이다. push는 스택 포인터를 하나씩 증가시키는 어셈 코드이다.

 

이제 sum 함수에 breakpoint를 걸고 실행시킨 후  스택의 상황을 볼것이다. breakpoint까지 한번에 실행하려면 c명령을 사용하면 된다.

sum의 함수 위치에 정확히 멈춰있는 것을 확인할 수 있다.

sum 함수가 call 되기 전의 상황은 다음과 같다.

sum 함수의 주소는 0x80484b4 이기 때문에 이 위치에 breakpoint를 걸고 함수 안으로 들어가면 스택 esp(0x00000001)위에 다음 인스트럭션의 주소가 쌓일 것이다.

이 함수의 어셈 코드를 확인해 보면 다음과 같다.

이 함수도 마찬가지로 push ebp, mov ebp,esp로 시작하고 있다. push ebp를 실행하면 그 이전 스택프레임(main)의 ebp 가 sum의 스택프레임에 push 되서 main의 ebp 0xbffff038이 esp에 들어갈 것이다. 

그리고 mov ebp, esp를 실행하면 현재 esp를 sum stack frame의 바닥 즉, sum의 ebp가 되서 sum의 ebp에는 main의 ebp가 있게 될 것이다. 

상단 첫번째 줄의 상태는 다음과 같다.

새 스택프레임의 ebp를 기준으로 return address는 ebp-4, 매개인자는 ebp-8, ebp-12에 들어가게 된다.

다음은 sum의 어셈 코드이다.

sum함수 안에서 func2 함수를 호출하고 있다. func 함수 주소에 breakpoint를 걸고 실행한 모습은 다음과 같다.

스택의 모습은 func2 stack frame, sum stack frame, main stack frame 가 차례로 있을 것이다. 

구간별로 나눠보면 다음과 같다.

현재 eip의 상황은 다음과 같다.

leave 실행 전의 스택 상황은 다음과 같다.

leave 를 실행한 후 스택의 상황은 다음과 같다.

위에서 빨간색으로 표시해둔 func2의 stack frame이 정리되며 ebp가 sum의 ebp로 바뀌고 esp도 sum의 esp로 바뀐다. 그리고 ret를 만나면 0x080484d8로 리턴해서 나머지 sum의 인스트럭션을 실행하고, sum의 스택프레임을 정리하고 나머지 main의 인스트럭션도 실행한 후 프로그램은 종료된다. 

+ Recent posts