가상 메모리 파헤치기: 2. Python bytes

가상 메모리 파헤치기: 2. Python bytes

이 글에서 우리는 앞서 알아봤던 1. C strings & /proc와 비슷한 일을 해볼 겁니다, 하지만 이번에는 실행중인 Python 3 스크립트가 대상입니다. 쉽게 끝나지 않을겁니다. 이를 통해 Python 3 내부가 어떻게 돼있는 지도 확인해봅시다.

들어가기 전에:
이 글은 Holberton school에서 연재되고 있는 Hack The Virtual Memory의 번역본입니다.

필요한 준비물

이 글을 완전히 습득하기 위해 이러한 것들을 알고 있어야 합니다:

  • C 프로그래밍 언어 기초
  • 약간의 Python 지식
  • 아주 간략한 리눅스 파일시스템과 셸에 대한 이해
  • /proc 파일시스템에 대한 기초 이해 (1. C strings & /proc 글을 먼저 보시는 것을 추천드립니다)

환경

모든 스크립트와 프로그램은 다음 환경에서 테스트됐습니다:

  • Ubuntu 14.04 LTS
    • Linux ubuntu 4.4.0-31-generic #50~14.04.1-Ubuntu SMP Wed Jul 13 01:07:32 UTC 2016 x86_64 x86_64 x86_64 GNU/Linux
  • gcc
    • gcc (Ubuntu 4.8.4-2ubuntu1~14.04.3) 4.8.4
  • Python 3
    • Python 3.4.3 (default, Nov 17 2016, 01:08:31)
    • [GCC 4.8.4] on linux

Python 스크립트

먼저 다음 스크립트를 이용해 돌고 있는 프로세스의 가상 메모리에서 "Holberton" 문자열을 찾아 바꿔봅시다:

#!/usr/bin/env python3
'''
Prints a b"string" (bytes object), reads a char from stdin
and prints the same (or not :)) string again
'''

import sys

s = b"Holberton"
print(s)
sys.stdin.read(1)
print(s)

bytes 객체에 대해

bytes vs str

우리가 확인할 수 있듯, 우리는 문자열을 담기 위해 bytes 객체를 사용(b 리터럴을 문자열 가장 앞에 붙였습니다)했습니다. bytes 타입은 문자열의 각 문자를 bytes(또는 경우에 따라 multibytes로 -- unicodeobject.h 파일을 통해 Python 3가 어떻게 문자열을 인코드하는지 알 수 있습니다)로 메모리에 기록합니다.
이는 string이 실행중인 스크립트 프로세스의 가상 메모리에서 ASCII 값의 연속일 것이라는 것을 확실하게 해줍니다.

기술적으로 s는 Python 문자열이 아닙니다.(하지만 여기서 중요한 부분은 아닙니다):

julien@holberton:~/holberton/w/hackthevm1$ python3
Python 3.4.3 (default, Nov 17 2016, 01:08:31) 
[GCC 4.8.4] on linux
Type "help", "copyright", "credits" or "license" for more information.
>>> s = "Betty"
>>> type(s)
<class 'str'>
>>> s = b"Betty"
>>> type(s)
<class 'bytes'>
>>> quit()

모든 것은 객체(object)입니다

Python에서의 모든 것은 객체입니다: 정수(integers), 문자열(strings), 바이트 배열(bytes), 함수(functions)를 포함해서 모든 것이 말이죠. 따라서 s = b"Holberton" 줄에서는 bytes 타입의 객체를 생성한 후 b"Holberton"을 메모리 어딘가에 기록하게 됩니다. 아마 heap 이겠죠. 객체나 바이트 배열이 필요한 공간을 예약해야 하니까요.

Python 스크립트에 대해 read_write_heap.py 돌려보기

참고: read_write_heap.py 파일은 1. C strings & /proc 글에서 우리가 작성했던 스크립트입니다.

위에서 작성한 코드를 실행해보고 read_write_heap.py 스크립트를 실행해봅시다:

julien@holberton:~/holberton/w/hackthevm1$ ./main.py 
b'Holberton'

이 때 main.py는 사용자가 Enter 키를 누를 때까지 기다리고 있습니다. 스크립트의 가장 첫 줄인 sys.stdin.read(1)에서 기다리고 있네요. 이제 read_write_heap.py 파일을 실행해봅시다:

julien@holberton:~/holberton/w/hackthevm1$ ps aux | grep main.py | grep -v grep
julien     3929  0.0  0.7  31412  7848 pts/0    S+   15:10   0:00 python3 ./main.py
julien@holberton:~/holberton/w/hackthevm1$ sudo ./read_write_heap.py 3929 Holberton "~ Betty ~"
[*] maps: /proc/3929/maps
[*] mem: /proc/3929/mem
[*] Found [heap]:
    pathname = [heap]
    addresses = 022dc000-023c6000
    permisions = rw-p
    offset = 00000000
    inode = 0
    Addr start [22dc000] | end [23c6000]
[*] Found 'Holberton' at 8e192
[*] Writing '~ Betty ~' at 236a192
julien@holberton:~/holberton/w/hackthevm1$

참 쉽죠? 예상했듯 heap에서 해당 문자열을 찾아 바꾸었습니다. 자 이제 Enter 키를 치면 b'~ Betty ~'가 출력되겠죠:


b'Holberton'
julien@holberton:~/holberton/w/hackthevm1$ 

잠깐? 이게 왜이럴까요?!

Wait, WAT?!

위에서 우리는 분명히 "Holberton" 문자열을 찾아 바꿨습니다, 그런데 왜 안바뀌어있죠?

함정에 빠지기 전에 한 가지 더 체크해봐야 할 것이 있습니다. 우리가 짠 스크립트는 문자열을 처음 찾았을 때 멈추게 되어있습니다. heap에서 같은 문자열이 몇 번 더 출현할 수도 있으니 더 돌려보자고요.

julien@holberton:~/holberton/w/hackthevm1$ ./main.py 
b'Holberton'

julien@holberton:~/holberton/w/hackthevm1$ ps aux | grep main.py | grep -v grep
julien     4051  0.1  0.7  31412  7832 pts/0    S+   15:53   0:00 python3 ./main.py
julien@holberton:~/holberton/w/hackthevm1$ sudo ./read_write_heap.py 4051 Holberton "~ Betty ~"
[*] maps: /proc/4051/maps
[*] mem: /proc/4051/mem
[*] Found [heap]:
    pathname = [heap]
    addresses = 00bf4000-00cde000
    permisions = rw-p
    offset = 00000000
    inode = 0
    Addr start [bf4000] | end [cde000]
[*] Found 'Holberton' at 8e162
[*] Writing '~ Betty ~' at c82162
julien@holberton:~/holberton/w/hackthevm1$ sudo ./read_write_heap.py 4051 Holberton "~ Betty ~"
[*] maps: /proc/4051/maps
[*] mem: /proc/4051/mem
[*] Found [heap]:
    pathname = [heap]
    addresses = 00bf4000-00cde000
    permisions = rw-p
    offset = 00000000
    inode = 0
    Addr start [bf4000] | end [cde000]
Can't find 'Holberton'
julien@holberton:~/holberton/w/hackthevm1$ 

한 번 밖에 없네요. 그럼 스크립트에 분명히 넣은 "Holberton" 문자열은 어디에 있는 걸까요? Python bytes는 대체 어디에 기록되는 걸까요? stack에 있는 걸까요? "[heap]"을 "stack"으로 바꿔 read_write_stack.py 파일을 만들어 다시 돌려봅시다:

#!/usr/bin/env python3
'''
Locates and replaces the first occurrence of a string in the stack
of a process

Usage: ./read_write_stack.py PID search_string replace_by_string
Where:
- PID is the pid of the target process
- search_string is the ASCII string you are looking to overwrite
- replace_by_string is the ASCII string you want to replace
search_string with
'''

import sys

def print_usage_and_exit():
    print('Usage: {} pid search write'.format(sys.argv[0]))
    sys.exit(1)

# check usage
if len(sys.argv) != 4:
    print_usage_and_exit()

# get the pid from args
pid = int(sys.argv[1])
if pid <= 0:
    print_usage_and_exit()
search_string = str(sys.argv[2])
if search_string  == "":
    print_usage_and_exit()
write_string = str(sys.argv[3])
if search_string  == "":
    print_usage_and_exit()

# open the maps and mem files of the process
maps_filename = "/proc/{}/maps".format(pid)
print("[*] maps: {}".format(maps_filename))
mem_filename = "/proc/{}/mem".format(pid)
print("[*] mem: {}".format(mem_filename))

# try opening the maps file
try:
    maps_file = open('/proc/{}/maps'.format(pid), 'r')
except IOError as e:
    print("[ERROR] Can not open file {}:".format(maps_filename))
    print("        I/O error({}): {}".format(e.errno, e.strerror))
    sys.exit(1)

for line in maps_file:
    sline = line.split(' ')
    # check if we found the stack
    if sline[-1][:-1] != "[stack]":
        continue
    print("[*] Found [stack]:")

    # parse line
    addr = sline[0]
    perm = sline[1]
    offset = sline[2]
    device = sline[3]
    inode = sline[4]
    pathname = sline[-1][:-1]
    print("\tpathname = {}".format(pathname))
    print("\taddresses = {}".format(addr))
    print("\tpermisions = {}".format(perm))
    print("\toffset = {}".format(offset))
    print("\tinode = {}".format(inode))

    # check if there is read and write permission
    if perm[0] != 'r' or perm[1] != 'w':
        print("[*] {} does not have read/write permission".format(pathname))
        maps_file.close()
        exit(0)

    # get start and end of the stack in the virtual memory
    addr = addr.split("-")
    if len(addr) != 2: # never trust anyone, not even your OS :)
        print("[*] Wrong addr format")
        maps_file.close()
        exit(1)
    addr_start = int(addr[0], 16)
    addr_end = int(addr[1], 16)
    print("\tAddr start [{:x}] | end [{:x}]".format(addr_start, addr_end))

    # open and read mem
    try:
        mem_file = open(mem_filename, 'rb+')
    except IOError as e:
        print("[ERROR] Can not open file {}:".format(mem_filename))
        print("        I/O error({}): {}".format(e.errno, e.strerror))
        maps_file.close()
        exit(1)

    # read stack
    mem_file.seek(addr_start)
    stack = mem_file.read(addr_end - addr_start)

    # find string
    try:
        i = stack.index(bytes(search_string, "ASCII"))
    except Exception:
        print("Can't find '{}'".format(search_string))
        maps_file.close()
        mem_file.close()
        exit(0)
    print("[*] Found '{}' at {:x}".format(search_string, i))

    # write the new stringprint("[*] Writing '{}' at {:x}".format(write_string, addr_start + i))
    mem_file.seek(addr_start + i)
    mem_file.write(bytes(write_string, "ASCII"))

    # close filesmaps_file.close()
    mem_file.close()

    # there is only one stack in our example
    break

위 스크립트(read_write_stack.py)는 전에 짰던 스크립트(read_write_heap.py)와 같은 방식으로 완전히 동일한 로직으로 돌아갑니다. heap 대신에 stack을 한 번 확인해봅시다:

julien@holberton:~/holberton/w/hackthevm1$ ./main.py
b'Holberton'

julien@holberton:~/holberton/w/hackthevm1$ ps aux | grep main.py | grep -v grep
julien     4124  0.2  0.7  31412  7848 pts/0    S+   16:10   0:00 python3 ./main.py
julien@holberton:~/holberton/w/hackthevm1$ sudo ./read_write_stack.py 4124 Holberton "~ Betty ~"
[sudo] password for julien: 
[*] maps: /proc/4124/maps
[*] mem: /proc/4124/mem
[*] Found [stack]:
    pathname = [stack]
    addresses = 7fff2997e000-7fff2999f000
    permisions = rw-p
    offset = 00000000
    inode = 0
    Addr start [7fff2997e000] | end [7fff2999f000]
Can't find 'Holberton'
julien@holberton:~/holberton/w/hackthevm1$ 

아, stack에도 없네요: 그럼 대체 어딨는 걸까요? 이제 Python 3 내부 동작을 확인해볼 차례입니다. 지금부터 재밌어지겠네요.

가상 메모리에서 문자열 찾기

참고: Python 3의 구현은 굉장히 많다는 것을 알아두시기 바랍니다. 이 글에서 우리는 가장 많이 사용되는 (C로 짜여진)CPython 구현체를 사용합니다. 여기서 이야기하는 모든 내용은 이 구현체에만 유효할 것입니다.

id

객체(object)가 가상 메모리의 어디에 있는지 찾는 가장 쉬운 방법이 존재합니다. (주의: objectstring이 아닙니다) CPython은 id() 라는 내장 함수를 구현해두고 있습니다: id()는 메모리에서의 객체 주소를 반환합니다.

Python 스크립트에서 id를 출력하는 줄을 추가하면 주소를 가져올 수 있겠죠(main_id.py):

#!/usr/bin/env python3
'''
Prints:
- the address of the bytes object
- a b"string" (bytes object)
reads a char from stdin
and prints the same (or not :)) string again
'''

import sys

s = b"Holberton"
print(hex(id(s)))
print(s)
sys.stdin.read(1)
print(s)
julien@holberton:~/holberton/w/hackthevm1$ ./main_id.py
0x7f343f010210
b'Holberton'

-> 0x7f343f010210. /proc를 살펴 실제 객체가 어디에 있는지 살펴봅시다.

julien@holberton:/usr/include/python3.4$ ps aux | grep main_id.py | grep -v grep
julien     4344  0.0  0.7  31412  7856 pts/0    S+   16:53   0:00 python3 ./main_id.py
julien@holberton:/usr/include/python3.4$ cat /proc/4344/maps
00400000-006fa000 r-xp 00000000 08:01 655561                             /usr/bin/python3.4
008f9000-008fa000 r--p 002f9000 08:01 655561                             /usr/bin/python3.4
008fa000-00986000 rw-p 002fa000 08:01 655561                             /usr/bin/python3.4
00986000-009a2000 rw-p 00000000 00:00 0 
021ba000-022a4000 rw-p 00000000 00:00 0                                  [heap]
7f343d797000-7f343de79000 r--p 00000000 08:01 663747                     /usr/lib/locale/locale-archive
7f343de79000-7f343df7e000 r-xp 00000000 08:01 136303                     /lib/x86_64-linux-gnu/libm-2.19.so
7f343df7e000-7f343e17d000 ---p 00105000 08:01 136303                     /lib/x86_64-linux-gnu/libm-2.19.so
7f343e17d000-7f343e17e000 r--p 00104000 08:01 136303                     /lib/x86_64-linux-gnu/libm-2.19.so
7f343e17e000-7f343e17f000 rw-p 00105000 08:01 136303                     /lib/x86_64-linux-gnu/libm-2.19.so
7f343e17f000-7f343e197000 r-xp 00000000 08:01 136416                     /lib/x86_64-linux-gnu/libz.so.1.2.8
7f343e197000-7f343e396000 ---p 00018000 08:01 136416                     /lib/x86_64-linux-gnu/libz.so.1.2.8
7f343e396000-7f343e397000 r--p 00017000 08:01 136416                     /lib/x86_64-linux-gnu/libz.so.1.2.8
7f343e397000-7f343e398000 rw-p 00018000 08:01 136416                     /lib/x86_64-linux-gnu/libz.so.1.2.8
7f343e398000-7f343e3bf000 r-xp 00000000 08:01 136275                     /lib/x86_64-linux-gnu/libexpat.so.1.6.0
7f343e3bf000-7f343e5bf000 ---p 00027000 08:01 136275                     /lib/x86_64-linux-gnu/libexpat.so.1.6.0
7f343e5bf000-7f343e5c1000 r--p 00027000 08:01 136275                     /lib/x86_64-linux-gnu/libexpat.so.1.6.0
7f343e5c1000-7f343e5c2000 rw-p 00029000 08:01 136275                     /lib/x86_64-linux-gnu/libexpat.so.1.6.0
7f343e5c2000-7f343e5c4000 r-xp 00000000 08:01 136408                     /lib/x86_64-linux-gnu/libutil-2.19.so
7f343e5c4000-7f343e7c3000 ---p 00002000 08:01 136408                     /lib/x86_64-linux-gnu/libutil-2.19.so
7f343e7c3000-7f343e7c4000 r--p 00001000 08:01 136408                     /lib/x86_64-linux-gnu/libutil-2.19.so
7f343e7c4000-7f343e7c5000 rw-p 00002000 08:01 136408                     /lib/x86_64-linux-gnu/libutil-2.19.so
7f343e7c5000-7f343e7c8000 r-xp 00000000 08:01 136270                     /lib/x86_64-linux-gnu/libdl-2.19.so
7f343e7c8000-7f343e9c7000 ---p 00003000 08:01 136270                     /lib/x86_64-linux-gnu/libdl-2.19.so
7f343e9c7000-7f343e9c8000 r--p 00002000 08:01 136270                     /lib/x86_64-linux-gnu/libdl-2.19.so
7f343e9c8000-7f343e9c9000 rw-p 00003000 08:01 136270                     /lib/x86_64-linux-gnu/libdl-2.19.so
7f343e9c9000-7f343eb83000 r-xp 00000000 08:01 136253                     /lib/x86_64-linux-gnu/libc-2.19.so
7f343eb83000-7f343ed83000 ---p 001ba000 08:01 136253                     /lib/x86_64-linux-gnu/libc-2.19.so
7f343ed83000-7f343ed87000 r--p 001ba000 08:01 136253                     /lib/x86_64-linux-gnu/libc-2.19.so
7f343ed87000-7f343ed89000 rw-p 001be000 08:01 136253                     /lib/x86_64-linux-gnu/libc-2.19.so
7f343ed89000-7f343ed8e000 rw-p 00000000 00:00 0 
7f343ed8e000-7f343eda7000 r-xp 00000000 08:01 136373                     /lib/x86_64-linux-gnu/libpthread-2.19.so
7f343eda7000-7f343efa6000 ---p 00019000 08:01 136373                     /lib/x86_64-linux-gnu/libpthread-2.19.so
7f343efa6000-7f343efa7000 r--p 00018000 08:01 136373                     /lib/x86_64-linux-gnu/libpthread-2.19.so
7f343efa7000-7f343efa8000 rw-p 00019000 08:01 136373                     /lib/x86_64-linux-gnu/libpthread-2.19.so
7f343efa8000-7f343efac000 rw-p 00000000 00:00 0 
7f343efac000-7f343efcf000 r-xp 00000000 08:01 136229                     /lib/x86_64-linux-gnu/ld-2.19.so
7f343f000000-7f343f1b6000 rw-p 00000000 00:00 0 
7f343f1c5000-7f343f1cc000 r--s 00000000 08:01 918462                     /usr/lib/x86_64-linux-gnu/gconv/gconv-modules.cache
7f343f1cc000-7f343f1ce000 rw-p 00000000 00:00 0 
7f343f1ce000-7f343f1cf000 r--p 00022000 08:01 136229                     /lib/x86_64-linux-gnu/ld-2.19.so
7f343f1cf000-7f343f1d0000 rw-p 00023000 08:01 136229                     /lib/x86_64-linux-gnu/ld-2.19.so
7f343f1d0000-7f343f1d1000 rw-p 00000000 00:00 0 
7ffccf1fd000-7ffccf21e000 rw-p 00000000 00:00 0                          [stack]
7ffccf23c000-7ffccf23e000 r--p 00000000 00:00 0                          [vvar]
7ffccf23e000-7ffccf240000 r-xp 00000000 00:00 0                          [vdso]
ffffffffff600000-ffffffffff601000 r-xp 00000000 00:00 0                  [vsyscall]
julien@holberton:/usr/include/python3.4$ 

우리가 만든 객체는 7f343f000000-7f343f1b6000 rw-p 00000000 00:00 0 메모리 공간에 존재합니다, heap도 stack도 아니네요. 우리가 앞서 보았던 결과가 진짜라는 것을 확인해줬습니다. 하지만 이게 문자열 자체가 같은 메모리 영역에 들어가 있다는 것을 의미하지는 않습니다. 예로, bytes 객체는 문자열을 가리키는 포인터를 가질 수도 있습니다, 그리고 문자열을 복사하지 않고요. 물론 여기서 우리는 우리가 발견한 메모리 공간에서 문자열을 찾아볼 겁니다. 하지만 이 공간이 과연 맞는지에 대한 확실한 이해가 필요하겠네요. 여기서는 낡은 "brute force"를 통해 문제를 해결하지 않을 예정입니다. 대신 bytes 객체에 대해 좀 더 알아보는 시간을 가져봅시다.

bytesobject.h

우리는 CPython 구현체로 이 실험을 해보고 있습니다. C로 구현되어 있으니 헤더 파일을 열어 확인해봅시다.

참고: Python 3 헤더 파일이 없다면 Ubuntu에서 다음 명령어로 다운로드 할 수 있습니다: sudo apt install python3-dev. 만약 같은 환경에서 실험중이라면 /usr/include/python3.4/ 경로에 헤더 파일이 존재할겁니다.

bytesobject.h 파일에 이렇게 나와있네요:

typedef struct {
    PyObject_VAR_HEAD
    Py_hash_t ob_shash;
    char ob_sval[1];

    /* Invariants:
     *     ob_sval contains space for 'ob_size+1' elements.
     *     ob_sval[ob_size] == 0.
     *     ob_shash is the hash of the string or -1 if not computed yet.
     */
} PyBytesObject;

이게 무엇을 의미할까요?

  • Python 3 bytes 객체는 내부적으로 PyBytesObject 타입의 값으로 표현됩니다.
  • ob_sval이 문자열을 갖고 있습니다.
  • 문자열은 0으로 끝납니다.
  • ob_size는 문자열의 길이를 갖고 있습니다. (ob_size를 찾기 위해 objects.h 파일의 PyObject_VAR_HEAD 매크로의 정의를 살펴야 합니다. 좀 있다 확인해보죠)

즉 우리가 만든 예제에서 bytes 객체를 출력할 수 있었다면 우리는 이를 확인할 수 있습니다:

  • ob_sval: "Holberton" -> Bytes values: 48 6f 6c 62 65 72 74 6f 6e 00
  • ob_size: 9

우리가 앞서 알아봤던 것들을 깔고 보면 이것이 의미하는 것은 문자열은 bytes 객체 "내에" 존재한다는 것입니다. 이제 문자열이 어디에 있는지 찾는 확실한 다른 방법을 터득했네요: 메모리에서 실제 객체를 찾아보는 것.

bytes 객체를 메모리에서 찾아보기

만약 우리가 PyBytesObject 값을 직접 확인하길 원한다면 C로 함수를 짜서 Python에서 C 함수를 호출해야 합니다. Python에서 C 함수를 호출하는 방법은 수없이 많지만 여기서는 동적 라이브러리(dynamic library)를 이용하는 가장 간단한 방법을 사용할 것입니다.

C 함수 만들기

Python 객체를 인자로 받고 실제 문자열의 주소(외에도 많은 여러 객체 정보)를 탐색하는 C 코드를 짜보겠습니다.

함수의 프로토타입은 void print_python_bytes(PyObject *p); 입니다, p가 우리가 원하는 객체(따라서 p는 가상 메모리에서의 객체 주소를 갖습니다)의 포인터고요. 뭐 값을 리턴할 필요는 없겠죠.

object.h

여기서 우리가 인자로 PyBytesObject를 받지 않는다는 것을 확인해볼 수 있었을 겁니다. 이를 이해하기 위해 object.h 헤더 파일을 살펴봅시다:

/* Object and type object interface */

/*
Objects are structures allocated on the heap.  Special rules apply to
the use of objects to ensure they are properly garbage-collected.
Objects are never allocated statically or on the stack; they must be
...
*/
  • "Objects are never allocated statically or on the stack(객체는 정적으로 또는 stack에 할당되지 않습니다)" -> 네, 이제 왜 stack에 없었는지 알았네요.
  • "Objects are structures allocated on the heap(객체는 heap에 할당되는 구조체입니다)" -> 잠시만요.. 뭐라고요? 우리는 분명 문자열을 heap에서 찾아봤고 heap에는 분명히 없었습니다. 혼란스럽네요. 이에 대해서는 다른 글에서 다뤄봅시다. :)

이 파일에서 찾을 수 있던 다른 내용은:

/*
...
Objects do not float around in memory; once allocated an object keeps
the same size and address.  Objects that must hold variable-size data
...
*/
  • "Objects do not float around in memory; once allocated an object keeps the same size and address(객체는 메모리상에 떠돌아다니지 않습니다; 한 번 할당된 객체는 같은 사이즈와 주소를 보존합니다)" 좋습니다. 이 내용은 만약 우리가 문자열을 제대로 수정한다면 그 문자열은 계속 수정된 상태로 같은 주소에 유지된다는 거네요.
  • "once allocated(한 번 할당되면..)" -> 할당? 그런데 heap이 아니고요? 혼란스럽네요. 이 내용도 다른 글에서 다뤄봅시다. :)
/*
...
Objects are always accessed through pointers of the type 'PyObject *'.
The type 'PyObject' is a structure that only contains the reference count
and the type pointer.  The actual memory allocated for an object
contains other data that can only be accessed after casting the pointer
to a pointer to a longer structure type.  This longer type must start
with the reference count and type fields; the macro PyObject_HEAD should be
used for this (to accommodate for future changes).  The implementation
of a particular object type can cast the object pointer to the proper
type and back.
...
*/
  • "Objects are always accessed through pointers of the type 'PyObject *'(객체는 항상 'PyObject *' 타입의 포인터를 통해 접근됩니다." -> 이것이 바로 우리가 왜 PyBytesObject 대신 PyObject 타입의 포인터를 인자로 받아야 하는지에 대한 이유입니다.
  • "The actual memory allocated for an object contains other data that can only be accessed after casting the pointer to a pointer to a longer structure type.(실제 메모리는 긴 구조체를 가리키는 포인터의 포인터를 캐스팅한 후에 접근이 가능한 다른 데이터를 포함하고 있는 객체를 위해 할당됩니다)" -> 자 그럼 우리는 모든 정보를 얻기 위해 인자로 받은 포인터를 PyBytesObject *로 캐스팅해야 합니다. 이것이 가능한 이유는 PyBytesObjectPyObject로 시작하는 PyVarObject로 시작하기 때문입니다:
/* PyObject_VAR_HEAD defines the initial segment of all variable-size
 * container objects.  These end with a declaration of an array with 1
 * element, but enough space is malloc'ed so that the array actually
 * has room for ob_size elements.  Note that ob_size is an element count,
 * not necessarily a byte count.
 */
#define PyObject_VAR_HEAD      PyVarObject ob_base;
#define Py_INVALID_SIZE (Py_ssize_t)-1

/* Nothing is actually declared to be a PyObject, but every pointer to
 * a Python object can be cast to a PyObject*.  This is inheritance built
 * by hand.  Similarly every pointer to a variable-size Python object can,
 * in addition, be cast to PyVarObject*.
 */
typedef struct _object {
    _PyObject_HEAD_EXTRA
    Py_ssize_t ob_refcnt;
    struct _typeobject *ob_type;
} PyObject;

typedef struct {
    PyObject ob_base;
    Py_ssize_t ob_size; /* Number of items in variable part */
} PyVarObject;

-> 여기에 bytesobject.h에서 언급했던 ob_size가 있네요.

C 함수

위에서 본 것들을 종합해 바로 C 코드를 작성해봅시다(bytes.c):

#include "Python.h"

/**
 * print_python_bytes - prints info about a Python 3 bytes object
 * @p: a pointer to a Python 3 bytes object
 * 
 * Return: Nothing
 */
void print_python_bytes(PyObject *p)
{
     /* The pointer with the correct type.*/
     PyBytesObject *s;
     unsigned int i;

     printf("[.] bytes object info\n");
     /* casting the PyObject pointer to a PyBytesObject pointer */
     s = (PyBytesObject *)p;
     /* never trust anyone, check that this is actually
        a PyBytesObject object. */
     if (s && PyBytes_Check(s))
     {
          /* a pointer holds the memory address of the first byte
         of the data it points to */
          printf("  address of the object: %p\n", (void *)s);
          /* op_size is in the ob_base structure, of type PyVarObject. */
          printf("  size: %ld\n", s->ob_base.ob_size);
          /* ob_sval is the array of bytes, ending with the value 0:
         ob_sval[ob_size] == 0 */
          printf("  trying string: %s\n", s->ob_sval);
          printf("  address of the data: %p\n", (void *)(s->ob_sval));
          printf("  bytes:");
          /* printing each byte at a time, in case this is not
         a "string". bytes doesn't have to be strings.
         ob_sval contains space for 'ob_size+1' elements.
         ob_sval[ob_size] == 0. */
          for (i = 0; i < s->ob_base.ob_size + 1; i++)
          {
               printf(" %02x", s->ob_sval[i] & 0xff);
          }
          printf("\n");
     }
     /* if this is not a PyBytesObject print an error message */
     else
     {
          fprintf(stderr, "  [ERROR] Invalid Bytes Object\n");
     }
}

Python에서 C 함수 호출하기

동적 라이브러리(dynamic library) 만들기

앞서 말했듯 이제 우리는 "동적 라이브러리"를 이용해 Python 3에서 우리가 만든 함수를 호출할 겁니다. 다음 명령어로 C 파일을 컴파일해줍니다:

gcc -Wall -Wextra -pedantic -Werror -std=c99 -shared -Wl,-soname,libPython.so -o libPython.so -fPIC -I/usr/include/python3.4 bytes.c

Python 3 헤더 파일 경로를 포함시키는 것을 잊지 마세요: -I/usr/include/python3.4

명령을 실행하면 libPython.so라는 동적 라이브러리가 생성될 것입니다.

Python 3에서 동적 라이브러리 사용하기

우리가 만든 함수를 Python에서 사용하기 위해 Python 스크립트에 다음 줄을 추가합니다:

import ctypes

lib = ctypes.CDLL('./libPython.so')
lib.print_python_bytes.argtypes = [ctypes.py_object]

그리고 이렇게 해서 호출합니다:

lib.print_python_bytes(s)

새로운 Python 스크립트

새로 작성한 Python 3 스크립트입니다(main_bytes.py):

#!/usr/bin/env python3
'''
Prints:
- the address of the bytes object
- a b"string" (bytes object)
- information about the bytes object
And then:
- reads a char from stdin
- prints the same (or not :)) information again
'''

import sys
import ctypes

lib = ctypes.CDLL('./libPython.so')
lib.print_python_bytes.argtypes = [ctypes.py_object]

s = b"Holberton"
print(hex(id(s)))
print(s)
lib.print_python_bytes(s)

sys.stdin.read(1)

print(hex(id(s)))
print(s)
lib.print_python_bytes(s)

실행해봅시다!

julien@holberton:~/holberton/w/hackthevm1$ ./main_bytes.py 
0x7f04d721b210
b'Holberton'
[.] bytes object info
  address of the object: 0x7f04d721b210
  size: 9
  trying string: Holberton
  address of the data: 0x7f04d721b230
  bytes: 48 6f 6c 62 65 72 74 6f 6e 00

예상대로:

  • id()는 객체 스스로의 주소를 반환합니다. (0x7f04d721b210)
  • 우리가 만든 객체의 크기(ob_size)는 9입니다.
  • 우리가 만든 객체의 데이터인 "Holberton"은 48 6f 6c 62 65 72 74 6f 6e 00로 나와 있습니다. (그리고 bytesobject.h 파일에 나와있었듯 00으로 끝났습니다)

그리고... 드디어 우리가 찾는 문자열의 정확한 주소를 알아냈네요! 0x7f04d721b230 였습니다!

And There Was Much Rejoicing

rw_all.py

이제 무엇이 일어나고 있는지 좀 더 확인해봅시다. 맵핑된 메모리 공간을 "brute-force" 해보자고요. 문자열을 치환했던 스크립트를 바꿔봅시다. stack 또는 heap만을 보는 대신 읽고 쓸 수 있는 프로세스의 모든 메모리 공간을 확인해봅시다. 여기 코드가 있습니다:

#!/usr/bin/env python3
'''
Locates and replaces (if we have permission) all occurrences of
an ASCII string in the entire virtual memory of a process.

Usage: ./rw_all.py PID search_string replace_by_string
Where:
- PID is the pid of the target process
- search_string is the ASCII string you are looking to overwrite
- replace_by_string is the ASCII string you want to replace
search_string with
'''

import sys

def print_usage_and_exit():
    print('Usage: {} pid search write'.format(sys.argv[0]))
    exit(1)

# check usage
if len(sys.argv) != 4:
    print_usage_and_exit()

# get the pid from args
pid = int(sys.argv[1])
if pid <= 0:
    print_usage_and_exit()
search_string = str(sys.argv[2])
if search_string  == "":
    print_usage_and_exit()
write_string = str(sys.argv[3])
if search_string  == "":
    print_usage_and_exit()

# open the maps and mem files of the process
maps_filename = "/proc/{}/maps".format(pid)
print("[*] maps: {}".format(maps_filename))
mem_filename = "/proc/{}/mem".format(pid)
print("[*] mem: {}".format(mem_filename))

# try opening the file
try:
    maps_file = open('/proc/{}/maps'.format(pid), 'r')
except IOError as e:
    print("[ERROR] Can not open file {}:".format(maps_filename))
    print("        I/O error({}): {}".format(e.errno, e.strerror))
    exit(1)

for line in maps_file:
    # print the name of the memory region
    sline = line.split(' ')
    name = sline[-1][:-1];
    print("[*] Searching in {}:".format(name))

    # parse line
    addr = sline[0]
    perm = sline[1]
    offset = sline[2]
    device = sline[3]
    inode = sline[4]
    pathname = sline[-1][:-1]

    # check if there are read and write permissions
    if perm[0] != 'r' or perm[1] != 'w':
        print("\t[\x1B[31m!\x1B[m] {} does not have read/write permissions ({})".format(pathname, perm))
        continue

    print("\tpathname = {}".format(pathname))
    print("\taddresses = {}".format(addr))
    print("\tpermisions = {}".format(perm))
    print("\toffset = {}".format(offset))
    print("\tinode = {}".format(inode))

    # get start and end of the memoy region
    addr = addr.split("-")
    if len(addr) != 2: # never trust anyone
        print("[*] Wrong addr format")
        maps_file.close()
        exit(1)
    addr_start = int(addr[0], 16)
    addr_end = int(addr[1], 16)
    print("\tAddr start [{:x}] | end [{:x}]".format(addr_start, addr_end))

    # open and read the memory region
    try:
        mem_file = open(mem_filename, 'rb+')
    except IOError as e:
        print("[ERROR] Can not open file {}:".format(mem_filename))
        print("        I/O error({}): {}".format(e.errno, e.strerror))
        maps_file.close()

    # read the memory region
    mem_file.seek(addr_start)
    region = mem_file.read(addr_end - addr_start)

    # find string
    nb_found = 0;
    try:
        i = region.index(bytes(search_string, "ASCII"))
        while (i):
            print("\t[\x1B[32m:)\x1B[m] Found '{}' at {:x}".format(search_string, i))
            nb_found = nb_found + 1
            # write the new string
        print("\t[:)] Writing '{}' at {:x}".format(write_string, addr_start + i))
            mem_file.seek(addr_start + i)
            mem_file.write(bytes(write_string, "ASCII"))
            mem_file.flush()

            # update our buffer
        region.write(bytes(write_string, "ASCII"), i)

            i = region.index(bytes(search_string, "ASCII"))
    except Exception:
        if nb_found == 0:
            print("\t[\x1B[31m:(\x1B[m] Can't find '{}'".format(search_string))
    mem_file.close()

# close files
maps_file.close()

실행해봅시다!

julien@holberton:~/holberton/w/hackthevm1$ ./main_bytes.py 
0x7f37f1e01210
b'Holberton'
[.] bytes object info
  address of the object: 0x7f37f1e01210
  size: 9
  trying string: Holberton
  address of the data: 0x7f37f1e01230
  bytes: 48 6f 6c 62 65 72 74 6f 6e 00

julien@holberton:~/holberton/w/hackthevm1$ ps aux | grep main_bytes.py | grep -v grep
julien     4713  0.0  0.8  37720  8208 pts/0    S+   18:48   0:00 python3 ./main_bytes.py
julien@holberton:~/holberton/w/hackthevm1$ sudo ./rw_all.py 4713 Holberton "~ Betty ~"
[*] maps: /proc/4713/maps
[*] mem: /proc/4713/mem
[*] Searching in /usr/bin/python3.4:
    [!] /usr/bin/python3.4 does not have read/write permissions (r-xp)
...
[*] Searching in [heap]:
    pathname = [heap]
    addresses = 00e26000-00f11000
    permisions = rw-p
    offset = 00000000
    inode = 0
    Addr start [e26000] | end [f11000]
    [:)] Found 'Holberton' at 8e422
    [:)] Writing '~ Betty ~' at eb4422
...
[*] Searching in :
    pathname = 
    addresses = 7f37f1df1000-7f37f1fa7000
    permisions = rw-p
    offset = 00000000
    inode = 0
    Addr start [7f37f1df1000] | end [7f37f1fa7000]
    [:)] Found 'Holberton' at 10230
    [:)] Writing '~ Betty ~' at 7f37f1e01230
...
[*] Searching in [stack]:
    pathname = [stack]
    addresses = 7ffdc3d0c000-7ffdc3d2d000
    permisions = rw-p
    offset = 00000000
    inode = 0
    Addr start [7ffdc3d0c000] | end [7ffdc3d2d000]
    [:(] Can't find 'Holberton'
...
julien@holberton:~/holberton/w/hackthevm1$ 

그리고 이제 main_bytes.py에서 엔터키를 누르면...

julien@holberton:~/holberton/w/hackthevm1$ ./main_bytes.py 
0x7f37f1e01210
b'Holberton'
[.] bytes object info
  address of the object: 0x7f37f1e01210
  size: 9
  trying string: Holberton
  address of the data: 0x7f37f1e01230
  bytes: 48 6f 6c 62 65 72 74 6f 6e 00

0x7f37f1e01210
b'~ Betty ~'
[.] bytes object info
  address of the object: 0x7f37f1e01210
  size: 9
  trying string: ~ Betty ~
  address of the data: 0x7f37f1e01230
  bytes: 7e 20 42 65 74 74 79 20 7e 00
julien@holberton:~/holberton/w/hackthevm1$

BOOOOM!

yay

마치며

가까스로 Python 3 스크립트를 이용해 문자열을 수정할 수 있었습니다. 좋았어요! 그런데 몇 가지 의문이 남습니다:

  • [heap] 공간에 있던 "Holberton" 문자열은 대체 뭘까요?
  • 어떻게 Python 3은 heap 공간 밖에서 메모리를 할당할까요.
  • 만약 Python 3이 heap을 사용하지 않는다면, objects.h에 나와있던 "objects are structes allocated on the heap(객체는 heap에 할당되는 구조체입니다)"은 대체 무슨 내용일까요?

위 내용들은 다른 글에서 다뤄보도록 하겠습니다. :)

예제 파일

이 글에 있는 예제 파일은 이 레포에 올라가 있습니다.