So, a discussion came up on python-mentors about how to fix some of the timing attacks that I talked about in my pycon talk, and specifically how to solve them in a general way. I’ve previously tried to do this using multiprocessing- which works pretty well assuming that you’re able to fork without biting the memory bullet and that the return value of the function you’re wrapping is pickleable. If either of those things aren’t true though, you have a problem- and so I tried today to do it with threading. Here’s the result:
#! /usr/bin/env python3
import time
import queue
import threading
import functools
class ConstantTime:
"""Runs for a fixed period of time, then returns no matter what.
The `timeout` argument specifies, within the limits imposed by the host
operating system, the amount of time this function will run for.
The `margin_of_error` argument specifies how long before the timeout to
begin trying to claw our way back out of the decorated function. It should
be at least as long as you expect it to take to exit from an arbitrary point
in the decorated function.
Both are specified in seconds.
"""
def __init__(self, timeout=1, margin_of_error=.1):
self.timeout = timeout
self.inner_timeout = timeout - margin_of_error
def __call__(self, f):
@functools.wraps(f)
def inner(*args, **kwargs):
# this will wait for a fixed period of time before join()ing
def sleeper(): time.sleep(self.timeout)
# this pushes the return value back up to us
q = queue.Queue()
def wrap(*args, **kwargs):
q.put_nowait(f(*args, **kwargs))
# create the thread objects
timed_t = threading.Thread(target=wrap, args=args, kwargs=kwargs)
timer_t = threading.Thread(target=sleeper)
# set the daemon flag on the timed thread so we can exit if we must
timed_t.daemon = True
# we start the timed thread first and let the margin of error
# soak up the difference.
timed_t.start()
timer_t.start()
timed_t.join(timeout=self.inner_timeout)
timer_t.join()
# now we try to return, blowing up properly if we don't have a legit
# return value
try:
return q.get_nowait()
except queue.Empty:
raise Exception("Exited wrapped function before it completed")
# return the wrapped function to complete the decoration
return inner
Works ok, right? Not so fast.
@ConstantTime(timeout=2)
def f(n):
return pow(65537, 2 >> n)
@ConstantTime(timeout=2)
def g(n):
while n:
time.sleep(1.5)
n -= 1
These two things will behave very differently here; the first, which does not release the GIL, will sit and spin no matter how hard we try to get out of it. The second, which does release the GIL, will exit on time. This effectively means that the code above will leak how long the last GIL-locking operation takes after the buzzer if one happens to be occurring when that happens. Not a good sign- and it goes to show you how hard it can be to engineer these things right.