Python, like most modern programming languages, handles runtime errors with exceptions. For every Python programmer, the try ... except block is basic equipment. Exceptions are used for runtime errors and operating-system errors, and they are also closely connected with Python’s dynamic typing, as shown by an example of exotic uses of exceptions. This post looks at code that seems perfectly fine but contains a serious bug. The idea came from an article on Zdroják.cz.
The article discussed authentication and authorisation in the web.py framework. Petr Horacek described his own module. The example code also contained a bug in exception handling during login; see my comment. The bug concerns Python 2.x. In Python 3, the notation that leads to it is a syntax error.
The bug is not obvious at first glance. The code works, everything is syntactically valid, and yet something is wrong. First, a module defining custom exceptions as classes, say mod_exc.py:
class UserNotFound(Exception):
pass
class WrongPassword(Exception):
passNow the code. Imagine POST() as a handler for an HTTP POST request with user and passwd parameters. These values are verified in login() against PWD_DICT. If everything is fine, login() succeeds; otherwise it raises UserNotFound or WrongPassword.
import mod_exc
import sys
PWD_DICT = {'honzas': 'hPsWd'}
def login(user, passwd):
try:
if PWD_DICT[user] != passwd:
print 'wrong password'
raise mod_exc.WrongPassword
except KeyError:
print 'user not found'
raise mod_exc.UserNotFound
def POST(**args):
usr = args.get('user')
passwd = args.get('passwd')
try:
login(usr, passwd)
print 'access granted'
except mod_exc.UserNotFound, mod_exc.WrongPassword:
print 'access denied'
print
POST(user='honzas', passwd='hPsWd')
POST(user='test', passwd='hPsWd')
POST(user='honzas', passwd='foobar')
POST(user='test', passwd='hPsWd')
POST(user='honzas', passwd='foobar')The output is as expected:
access granted
user not found
access denied
wrong password
access denied
user not found
access denied
wrong password
access deniedThe code still contains a fundamental, and in this context potentially security-relevant, bug. The first indicator appears when we change the sequence of POST() calls:
POST(user='honzas', passwd='hPsWd')
POST(user='honzas', passwd='foobar')
POST(user='test', passwd='hPsWd')The output becomes a traceback:
access granted
wrong password
Traceback (most recent call last):
File "exceptions.py", line 28, in
POST(user='honzas', passwd='foobar')
File "exceptions.py", line 20, in POST
login(usr, passwd)
File "exceptions.py", line 10, in login
raise mod_exc.WrongPassword
mod_exc.WrongPasswordTracking the Bug
Add debug prints to POST():
def POST(**args):
usr = args.get('user')
passwd = args.get('passwd')
try:
login(usr, passwd)
print 'access granted'
except mod_exc.UserNotFound, mod_exc.WrongPassword:
print 'access denied'
type, value, traceback = sys.exc_info()
if type == mod_exc.UserNotFound:
print '... user not found'
else:
print '... wrong password'
printThe output is surprising:
access granted
user not found
access denied
... user not found
wrong password
access denied
... user not foundIn every case the caught exception appears to be UserNotFound, even when the username is correct and the password is wrong.
Where Did the Bug Probably Happen?
The bug was created by the historical design of Python 2.x. Python 3 prevents it at syntax-check time. The cause is missing parentheses in the except branch, where the code was meant to catch exceptions of two classes:
except mod_exc.UserNotFound, mod_exc.WrongPassword:In Python 2 this is equivalent to catching UserNotFound and binding the caught exception under the name mod_exc.WrongPassword:
except mod_exc.UserNotFound as mod_exc.WrongPassword:Instead of catching either exception class, it always catches only UserNotFound and stores the caught exception into the mod_exc module under the name WrongPassword. After that, raising mod_exc.WrongPassword actually raises UserNotFound again.
The Fix
The fix is simple: use parentheses around the tuple of exception classes. With the corrected POST() function, the output is:
access granted
user not found
access denied
... user not found
wrong password
access denied
... wrong passwordConclusion
Although the problem affects only Python 2.x, its consequences are serious because Python 2 was widespread and actively supported at the time. In a broader sense, this resembles overwriting part of the program through a carefully chosen sequence of inputs, which can lead to security bugs.
Notice that the bug is mainly enabled by defining exceptions in a separate module, importing that module, and referring to exceptions with dotted notation. That makes it possible to overwrite one name permanently with another object and change the behaviour of code that runs later.
One final kick at Python 2.x: it is good that this notation is no longer supported in Python 3. It is historical baggage from Python’s rather chaotic early development.