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
Conversation
ad135f8
to
b2e0de7
Compare
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.
b2e0de7
to
ea501c6
Compare
rope/base/oi/doa.py
Outdated
@@ -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 |
There was a problem hiding this comment.
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.)
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Much nicer, thank you.
rope/base/oi/doa.py
Outdated
while self.data_port < 4000: | ||
try: | ||
self.server_socket.bind(('', self.data_port)) | ||
self.server_socket.bind(('127.0.0.1', self.data_port)) |
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
localhost might work.
rope/base/oi/doa.py
Outdated
continue | ||
|
||
digest = hmac.new(self.key, buf_data, hashlib.sha256).digest() | ||
if buf_digest != digest: |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
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).
ropetest/doatest.py
Outdated
import multiprocessing | ||
try: | ||
import pickle | ||
except ImportError: |
There was a problem hiding this comment.
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
There was a problem hiding this comment.
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!
ropetest/doatest.py
Outdated
self.assertEqual(0, len(received_objs)) | ||
|
||
def test_CVE_2014_3539_sanity(self): | ||
# Tests that sending valid, encrypted data on the socket does work. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
signed, not encrypted
There was a problem hiding this comment.
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
rope/base/oi/doa.py
Outdated
continue | ||
|
||
digest = hmac.new(self.key, buf_data, hashlib.sha256).digest() | ||
if buf_digest != digest: |
There was a problem hiding this comment.
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?
rope/base/oi/doa.py
Outdated
@@ -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)) |
There was a problem hiding this comment.
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
There was a problem hiding this comment.
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
There was a problem hiding this 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): |
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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, |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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
Implemented the documentation change, not sure what github wants me to do to close the review!
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.