Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Perform signature verification on pickled data transferred over sockets #251

Merged
merged 4 commits into from Aug 8, 2018

Conversation

soupytwist
Copy link
Contributor

@soupytwist soupytwist commented Aug 6, 2018

Before unpickling anything, ensure that it has a valid digital
signature using a randomly-generated shared key. In order for an attacker
to send or tamper with data on the same socket, they must know this key
to compute a valid signature.

Fixes #105 by requiring some authentication before unpickling.

Before unpickling anything, ensure that it has a valid digital
signature using a randomly-generated shared key. In order for an attacker
to send or tamper with data on the same socket, they must know this key
to compute a valid signature.
@@ -114,24 +118,55 @@ class _SocketReceiver(_MessageReceiver):
def __init__(self):
self.server_socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
self.data_port = 3037
self.key = uuid.uuid4().bytes
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

could just use os.urandom(16) and it'd be more obviously correct. (It's not obvious without reading its source that uuid4 uses a CSPRNG, although it "should", and does.)

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In fact, I think usually people prefer to use the digest size as the key size, so 16 is half as big as it should be. I'd replace with os.urandom(32). (Without making any statement about whether 128 bits of security is too few or whatever). Trying to remember where I heard this but can't find it.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Much nicer, thank you.

while self.data_port < 4000:
try:
self.server_socket.bind(('', self.data_port))
self.server_socket.bind(('127.0.0.1', self.data_port))
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

can you use localhost or similar for better ipv6 something?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

localhost might work.

continue

digest = hmac.new(self.key, buf_data, hashlib.sha256).digest()
if buf_digest != digest:
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

use hmac.compare_digest to avoid timing attacks. Otherwise people can discover the correct hmac for their malicious input by continually sending requests and seeing how long it takes to be rejected.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I see, but this is only available for python 2.7+ (rope supports as low as 2.6). I could use the stronger protection if available, but I don't think there's much possibility of this attack because 1) the attacker doesn't get feedback of whether their attempt is successful and 2) the process is very short-lived so any brute-force style attack like this would take too long.

Copy link

@ssbr ssbr Aug 7, 2018

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

the attacker doesn't get feedback of whether their attempt is successful

I think this is untrue. The longer it takes for the connection to be closed, the more successful they have been.

the process is very short-lived so any brute-force style attack like this would take too long.

This may be so, but it's a precondition that might change later.

In general I don't like security-critical code that is "delicate" like this. If we ever had a long-lived process, would anyone think to fix this code to restore its security properties? I doubt it. It's better to be straightforward. This is probably how I'd write it:

def _compat_compare_digest(a, b):
    if len(a) != len(b): return False
    difference = 0
    for (a_char, b_char) in zip(a, b):
        difference |= ord(a_char) ^ ord(b_char)
    return difference == 0

try:
    from hmac import compare_digest
except ImportError:
    compare_digest = _compat_compare_digest

Test case:

    def test_eq_empty(self):
        self.assertTrue(doa._compat_compare_digest('', ''))


    def test_eq_nonempty(self):
        self.assertTrue(doa._compat_compare_digest('abc', 'abc'))


    def test_neq_samelength(self):
        self.assertFalse(doa._compat_compare_digest('abc', 'abd'))


    def test_neq_difflength(self):
        self.assertFalse(doa._compat_compare_digest('abc', 'abcd'))

It's much easier to drop support for 2.6. Who uses 2.6, anyway?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't know who uses 2.6, or even that it's entirely supported (but the tests do run and pass).

import multiprocessing
try:
import pickle
except ImportError:
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

other way around. pickle will always succeed, it's cPickle that might not depending on Python version / implementation.

So it should be:

try:
    import cPickle as pickle
except ImportError:
    import pickle

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Huh, I copied this from elsewhere without noticing. Yeah, that's not right!

self.assertEqual(0, len(received_objs))

def test_CVE_2014_3539_sanity(self):
# Tests that sending valid, encrypted data on the socket does work.
Copy link

@ssbr ssbr Aug 6, 2018

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

signed, not encrypted

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Artifact from a previous idea, ty :)

- Bind to localhost instead of 127.0.0.1
- Update key generation method
- Import cPickle if available
continue

digest = hmac.new(self.key, buf_data, hashlib.sha256).digest()
if buf_digest != digest:
Copy link

@ssbr ssbr Aug 7, 2018

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

the attacker doesn't get feedback of whether their attempt is successful

I think this is untrue. The longer it takes for the connection to be closed, the more successful they have been.

the process is very short-lived so any brute-force style attack like this would take too long.

This may be so, but it's a precondition that might change later.

In general I don't like security-critical code that is "delicate" like this. If we ever had a long-lived process, would anyone think to fix this code to restore its security properties? I doubt it. It's better to be straightforward. This is probably how I'd write it:

def _compat_compare_digest(a, b):
    if len(a) != len(b): return False
    difference = 0
    for (a_char, b_char) in zip(a, b):
        difference |= ord(a_char) ^ ord(b_char)
    return difference == 0

try:
    from hmac import compare_digest
except ImportError:
    compare_digest = _compat_compare_digest

Test case:

    def test_eq_empty(self):
        self.assertTrue(doa._compat_compare_digest('', ''))


    def test_eq_nonempty(self):
        self.assertTrue(doa._compat_compare_digest('abc', 'abc'))


    def test_neq_samelength(self):
        self.assertFalse(doa._compat_compare_digest('abc', 'abd'))


    def test_neq_difflength(self):
        self.assertFalse(doa._compat_compare_digest('abc', 'abcd'))

It's much easier to drop support for 2.6. Who uses 2.6, anyway?

@@ -118,11 +117,11 @@ class _SocketReceiver(_MessageReceiver):
def __init__(self):
self.server_socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
self.data_port = 3037
self.key = uuid.uuid4().bytes
self.key = bytes(os.urandom(32))
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

os.urandom always returns bytes in all python versions

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

>>> import os
>>> type(os.urandom(32))
<type 'str'>

ah, I forgot there's no distinction in python2. Done.

- remove redundant call to bytes
- use hmac.compare_digest
mcepl
mcepl previously requested changes Aug 7, 2018
Copy link
Contributor

@mcepl mcepl left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please consider indicated changes. Otherwise, I really like it and it looks pretty straightforward, so it should work.

@@ -11,6 +14,20 @@
import threading


def _compat_compare_digest(a, b):
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I would say that this function would deserve at least some comment explaining its function. Normal humans among us (and most Python programmers are normal humans) are not used to bitwise operations. How is this effectively different from a == b?

Also, do you think support for Python < 2.7.7 is that crucial? (we have hmac.compare_digests since that release) Python 2.6 is in my opinion only part of RHEL-6 which is effectively frozen and on the life support these days. Where else you can meet Python 2.6?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't know that it is crucial, but deprecating an old version to support this function doesn't seem worthwhile imo (it's pretty simple). I will document it, though, good point.

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

FWIW the bit math here is actually pretty standard (I wrote it from memory). It's still not necessarily constant time but what can you do?

break
except socket.error:
self.data_port += 1
self.server_socket.listen(1)

def get_send_info(self):
return str(self.data_port)
return '%d:%s' % (self.data_port,
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If you were using random.SystemRandom(), you could use its methods, for example:

rd = random.SystemRandom()
rand_str = {:X}'.format(rd.getrandbits(25))

with slightly more predictable and manageable results.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think you meant this line, but I'm not sure how it's different than urandom that I am using now. That seems just as simple/manageable to me for this case.

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not sure I understand the suggestion here. random.SystemRandom is just a wrapper around urandom that lets you get other things like random integers, floats, etc. -- but if you just want a random string, urandom is easier.

- Document _compat_compare_digest
@soupytwist soupytwist dismissed mcepl’s stale review August 8, 2018 18:09

Implemented the documentation change, not sure what github wants me to do to close the review!

@soupytwist soupytwist merged commit b01da7a into master Aug 8, 2018
@soupytwist soupytwist deleted the cve-2014-3539 branch August 8, 2018 18:10
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

None yet

3 participants