How to Deal With Strings in Python

TL;DR

Python에서서의 문자열 인코딩 방식을 설명하기 위해 ASCIIUnicode에 대해 간단히 설명한다. Python2에서는 디폴트 인코딩 방식이 ASCII이지만 Python3에서는 UTF-8을 채택했다. UTF-8Unicode 인코딩 방식 중 하나로 전세계에서 널리 쓰이는 인코딩 방식이다.

Python2와 Python3에서 문자열을 다루는 방식이 바뀌었으므로, payload를 작성할 때 Python2에 익숙한 player가 Python3를 사용하기 위해서는 Unicode로 인코딩한 byte 문자열과 일반 문자열의 차이를 알고 있어야 한다.

ASCII와 Unicode

ASCIIUnicode에 대해 간단히 알아보자.

아스키 (ASCII, American Standard Code for Information Interchange)

  • 1960년대 미국에서 정의한 표준화한 부호체계

ASCII 코드는 7bit 즉, 128(=2^7)개의 고유한 값만 사용한다. 컴퓨터의 기본 저장 단위는 1byte(=8bits)인데, ASCII 코드는 7bit만 사용한다. 나머지 1bit는 Parity Bit인데, 이는 통신 에러 검출을 의미한다.

10진수로 0부터 127, 16진수로 0x00부터 0x7F의 범위로 문자열 Char 즉, 고유값을 표현할 수 있다.

7bit로 표현할 수 있는 ASCII 코드는 ‘영문’만을 표현할 수 있다. 다른 언어를 표현하기에는 7bit로는 역부족이었다. 그래서 8bits로 확장한 아스키 코드가 등장했는데, 이를 ANSI 코드라고 부르기로 했다더라.

ANSI 코드는 8bits로 표현할 수 있으니 256(=2^8)개의 값을 쓸 수 있는데, 여전히 ‘한글’을 포함한 비유럽 국가의 문자를 표현하기에는 역부족이었다. 그래서 유니코드 (Unicode)라는 전 세계 언어의 문자를 정의하기 위한 국제 표준 코드가 등장했다.

유니코드 (Unicode)

유니코드ASCII 코드ANSI 코드보다 훨씬 크게 용량을 2byte(=2^16, 65536)로 확장한 코드이다. 무려 65536(=2^16)개나 문자를 저장할 수 있었다. 참고로 유니코드 3.0버전까지는 이를 기본 언어판 (BMP, Basic Multilingual Plane)이라고 불렀다. 비트맵이 아니다.

0x0000부터 0xFFFF까지 표현이 가능하다.

하지만 이는 또 세상의 모든 언어를 감당하기가 힘들었다. 그래서 유니코드 3.0버전부터 보충 언어판 (Supplementary Plane)을 정의했다. 이때 한자가 가장 많이 할당받았다고 한다. 그런데 이것은 bit로 어떻게 표현할 수 있을까?

사실 기본 언어판에는 대행코드 영역이란 것이 존재한다. 상위 대행코드와 하위 대행코드를 조합해서 보충 언어판의 문자를 표현한다. 즉, 2byte가 꼭 한 개의 문자를 의미하는 것은 아니다. 한개를 읽어보고 대행코드이면 그 다음 한개를 더 읽어 한 문자를 완성한다.

기본 언어판의 대행코드 영역은 아래와 같다.

1
2
상위 대행코드 (High Surrogates) : D800 - DBFF
하위 대행코드 (Low Surrogates) : DC00 - DFFF

정리하자면 유니코드는 2byte로만 표현할 수 있다.는 완전히 틀린 소리이다. 결국 유니코드는 모든 문자에 index(Unidocde code point, 고유한 값)를 부여한 것이다.

1
2
3
'A'라는 글자는 0x0041(U+0041)이라는 index를 가진다.
'a'라는 글자는 0x0061(U+0061)이라는 index를 가진다.
'가'라는 글자는 0xAC00(U+AC00)이라는 index를 가진다.

참고로 U+라는 접두어가 붙어있으면 유니코드라는 의미이다. U+0000 ~ U+007FASCII 문자를 모두 표현할 수 있다. 참고로 U+AC00 ~ U+D7AF로 한글 음절을 표현할 수 있다.

유니코드를 표현하는 방법

유니코드의 index를 표현하는 방법에 대해 알아보자. 이러한 index는 여러 형식으로 변환될 수 있는데, 그 중 개인적으로 가장 익숙한 UTF-8 인코딩을 알아봤다.

그 전에 궁금증이 있다. index를 바로 컴퓨터에 저장하면 안되는 걸까? 왜 컴퓨터에 저장하기 위해서 또 변환을 해야할까? 용량때문이다. 예를 들어, 1byte로 충분히 표현할 수 있는 A를 굳이 2byte로 저장한다면 용량 문제가 생길 것이다. 호환이 잘되고 용량을 적게 만들기 위해 유니코드의 index(숫자)를 컴퓨터에 효과적으로 할당해서 0과 1로 표현할 지 결정하는 인코딩 방식이 필요하다.

UTF-8 (가변길이 인코딩, Unicode Transformation Format)

유니코드가 인터페이스라면 UTF-8은 구현체라고 할 수 있다. UTF-8은 8bit 문자 인코딩 형식으로 유니코드 문자를 index에 따라 8bit(=1byte) ~ 4byte로 변환한다.

UTF-8은 가변바이트를 사용하기 때문에, 1byte로 표현이 충분한 A는 0x41로 표현한다. 이는 UTF-8ASCII 코드와 영문 영역에서는 100% 호환된다는 것을 의미한다.

Code point ↔ UTF-8 conversion

First code point Last code point Byte 1 Byte 2 Byte 3 Byte 4
U+0000 U+007F 0xxxxxxx
U+0080 U+07FF 110xxxxx 10xxxxxx
U+0800 U+FFFF 1110xxxx 10xxxxxx 10xxxxxx
U+10000 U+10FFFF 11110xxx 10xxxxxx 10xxxxxx 10xxxxxx

출처: https://en.wikipedia.org/wiki/UTF-8

위와 같이 유니코드 범위에 따라 UTF-8 인코딩의 byte 개수가 정해진다. 참고로 한글은 3byte로 표현된다.

Python에서서의 문자열 인코딩

컴퓨터가 저장할 수 유일한 것은 byte이다. 그래서 인코딩 즉, byte로 변환해야하는 과정을 거쳐야 한다. Python에서의 문자열 인코딩 방식을 알아보자.

Python2

Python2에서는 ASCII가 디폴트 인코딩 방법으로 되어있다. ASCII로는 한글을 표현할 수 없으므로, 한글을 표현하기 위해서 아래의 코드를 코드 맨 윗줄에 따로 명시해야한다.

1
# -*- coding: utf-8 -*-

Python3

Python3에서는 UTF-8이 디폴트 인코딩 방법이다. 그래서 모든 문자열은 유니코드로 처리된다. b''는 byte임을 의미한다. 디폴트로 UTF-8로 인코딩되어 byte 문자열로 표현했으므로 이것을 다시.decode()하면 UTF-8로 디코딩하여 문자열로 표현할 수 있다.

정리하면 인코딩 방식만 알면 byte 문자열을 다시 문자열로 디코딩할 수 있다는 의미다.

1
2
3
4
5
6
7
8
9
>>> b'jir4vvit'
b'jir4vvit'
>>> type(_)
<class 'bytes'>
>>>
>>> b'jir4vvit'.decode()
'jir4vvit'
>>> type(_)
<class 'str'>

요즘 Pwnable을 풀 때 Python3으로 작성하고 있다. 이것을 헷갈리고 가장 처음에 했던 실수는 아래와 같다. 지금 생각해보면 왜 이렇게 했었는지 이해가 안간다.

1
2
3
4
>>> str(b'jir4vvit')
"b'jir4vvit'"
>>> type(_)
<class 'str'>

문자열에 대한 이해를 제대로 하지 않고 저지른 실수이다. Python3니까 어디서 많이 본 b''으로 문자열을 감싸고 이걸 str()로 감싸서 p.send()했었다.

여러 시도 끝에 마음편하게 byte 문자열로 payload를 구성하기로 했다.

아래처럼 문자열로 payload를 구성하면 될 때도 있고 안될 때도 있다. 되는 건 운이 좋은 때고, 안될 때는 에러가 발생한다. (안되는 경우가 더 많을 것 같다.)

1
2
3
4
5
6
7
payload = ''
payload += 'A' * 0x50
payload += '\x00\x00\x00\x00\x00\x00\x00\x00' # rbp
payload += '\x83\x0c\x40\x00\x00\x00\x00\x00' # pop rdi
payload += p64(e.got['puts']).decode()
payload += '\xe0\x06\x40\x00\x00\x00\x00\x00' # puts plt
payload += p64(main).decode()

실제로 아래와 같은 디코드가 안된다는 에러를 받았었다.

1
2
3
4
Traceback (most recent call last):
File "/home/jir4vvit/ex.py", line 99, in <module>
payload += p64(binsh).decode()
UnicodeDecodeError: 'utf-8' codec can't decode byte 0xcf in position 1: invalid continuation byte

마음편히 byte로 보내면 저런 디코딩 에러를 만날 일이 없다. pwntools는 기본적으로 byte로 처리하기 때문에 payload를 아래처럼 byte로 작성해주면 정말정말 디코딩 에러를 만날 일이 없다.

1
2
3
4
5
6
7
payload = ''
payload += b'A' * 0x50
payload += p64(0) # rbp
payload += p64(pop_rdi)
payload += p64(e.got['puts'])
payload += p64(e.symbols['puts])
payload += p64(main)

또한 Python2와 다르게 byte 문자열과 문자열을 혼합해서 작성할 수 없다. 일관되게 작성해야 한다.

Reference

이 글을 작성할 당시엔 댓글기능이 없다. 틀린 말이나 첨언이 있다면 메일 한 통 부탁드립니다. 메일 주소는 블로그 메인 화면 에서 찾을 수 있습니다.