-
-
Notifications
You must be signed in to change notification settings - Fork 31.8k
gh-115952: Fix potential virtual memory allocation denial of service in the pickle module #119204
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
base: main
Are you sure you want to change the base?
gh-115952: Fix potential virtual memory allocation denial of service in the pickle module #119204
Conversation
Loading a small data which does not even involve arbitrary code execution could consume arbitrary large amount of memory. There were three issues: * PUT and LONG_BINPUT with large argument (the C implementation only). Since the memo is implemented in C as a continuous dynamic array, a single opcode can cause its resizing to arbitrary size. Now the sparsity of memo indices is limited. * BINBYTES, BINBYTES8 and BYTEARRAY8 with large argument. They allocated the bytes or bytearray object of the specified size before reading into it. Now they read very large data by chunks. * BINSTRING, BINUNICODE, LONG4, BINUNICODE8 and FRAME with large argument. They read the whole data by calling the read() method of the underlying file object, which usually allocates the bytes object of the specified size before reading into it. Now they read very large data by chunks.
I've marked this Draft for now as discussion on this on the security response team list is not complete. (we'll summarize that in a public issue once it has settled) |
I see this has been taken out of draft. Is the security response summary available yet? |
Lib/pickle.py
Outdated
@@ -304,11 +309,23 @@ def readline(self): | |||
else: | |||
return self.file_readline() | |||
|
|||
def safe_file_read(self, size): |
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.
Don't use an ill-defined word such as "safe" in the name. Just describe what it does. chunked_file_read()
makes more sense. Same with SAFE_BUF_SIZE
just call it _READ_BUF_SIZE
(with an underscore prefix so it isn't considered a public API).
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.
Note that there is _safe_read
in http.client
, although ironically it is vulnerable to this kind of issue.
I afraid that name like chunked_file_read
could be misleading, as the data is not read by fixed size chunks. I guess this is what leads you to misunderstanding about the O(N^2) behavior.
Modules/_pickle.c
Outdated
{ | ||
PyObject *old_item; | ||
|
||
if (idx >= self->memo_size) { | ||
if (idx > self->memo_len * 2 + (1 << 17)) { |
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.
where does (1 << 17) come from? that feels like it should be a constant or at least deserves a command as to what the significance of this value is and why this limit exists.
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.
It is just an arbitrary constant to make the existing tests pass. It should be larger than 2**16
, otherwise we will need to make input data for two tests much larger. I do not think that the real code needs more than 0 or 1. I have no good name for this. The Python code does not need this, as it does not have such limitation and is not vulnerable to this issue (but it is less efficient).
This is the part of this PR that I have the greatest doubts. It can make the unpickler to reject an unusual data that is accepted now, although such data never produced by the pickler (it can still be unpickled with the Python implementation).
I can make the C code more similar to the Python code (as a fallback). But this will add a lot of complexity, and I do not know whether there is a use case for this at all. It may be a hypothetical problem that never occurs in real world, except some artificial tests.
Modules/_pickle.c
Outdated
@@ -153,6 +153,9 @@ enum { | |||
|
|||
/* Prefetch size when unpickling (disabled on unpeekable streams) */ | |||
PREFETCH = 8192 * 16, | |||
/* Data larger that this will be read in chunks, to prevent extreme | |||
overallocation. */ | |||
SAFE_BUF_SIZE = 1 << 20, |
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.
let's not use "SAFE" as name. match whatever new name was similarly chosen in the Python code.
@@ -0,0 +1,3 @@ | |||
Fix a vulnerability in the :mod:`pickle` module which allowed to consume |
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.
suggested new wording:
:mod:`pickle` now avoids a potential allocated virtual memory
address space denial of service condition when
unpickling specially crafted data.
Pickle isn't for use on untrusted data and no privileges could be escalated, so calling this a vulnerability is a stretch. It also wasn't a bad one, it didn't consume memory, it just mapped virtual address space.
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 consumes memory. The mapped virtual address space is filled with data (at least in debug build).
Pickle isn't safe for untrusted data by default, but it can be made safe (by strictly restricting what data it can handle) after resolving this issue.
When you're done making the requested changes, leave the comment: |
I believe that was Serhiy indicating that more review and a resolution would be nice. I can't disagree with that, we just haven't had time. Security discussions were left hanging and need resuming (no urgency - it's pickle - which is not intended for untrusted data). There is no reason to keep them private, this isn't what I'd really call a vulnerability. Data that'd help decide if we should just do something like this anyways: Performance testing after the PR comments to fix O(N^2) issues are addressed. This PR makes the unpickle code more complicated (potentially slower) and causes pickle to consume twice as much memory temporarily when unpickling large data due to the extra chunked buffering copy while also making many more read calls to the underlying file while doing that, where it only made one in the past. I'll put a "do not submit" label on the PR until that's been investigated. (it's more of a "I'm not convinced we actually want this yet, even once the implementation is in good shape") |
I thought I left it in a draft state because I was planning to do something else. But after re-checking, I found nothing unfinished. Only later did I notice that it was Gregory who put it in a draft state, but it was already late at night to revive the discussion. |
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 afraid, @gpshead, that you misunderstood the problem and the solution.
- The current code not just allocates the address space. It fills it with data that takes time and causes swapping (at least in debug build). Just run the tests added in this PR with unmodified code.
- The complexity of the new read is O(N log(N)), not O(N^2). Note that the more data is already read, the larger next read chunk will be. And the initial chunk is pretty large (1 MiB), so most of user code will be not affected at all. The algorithm is triggered only when you read very large strings. For 1 GiB it will only add 10 reads, for 1 TiB -- 20 reads. If this looks as too high cost, we can increase the initial size or the multiplier (but the code will be less effective in treating intentional attacks).
Lib/pickle.py
Outdated
@@ -304,11 +309,23 @@ def readline(self): | |||
else: | |||
return self.file_readline() | |||
|
|||
def safe_file_read(self, size): |
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.
Note that there is _safe_read
in http.client
, although ironically it is vulnerable to this kind of issue.
I afraid that name like chunked_file_read
could be misleading, as the data is not read by fixed size chunks. I guess this is what leads you to misunderstanding about the O(N^2) behavior.
@@ -0,0 +1,3 @@ | |||
Fix a vulnerability in the :mod:`pickle` module which allowed to consume |
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 consumes memory. The mapped virtual address space is filled with data (at least in debug build).
Pickle isn't safe for untrusted data by default, but it can be made safe (by strictly restricting what data it can handle) after resolving this issue.
Modules/_pickle.c
Outdated
{ | ||
PyObject *old_item; | ||
|
||
if (idx >= self->memo_size) { | ||
if (idx > self->memo_len * 2 + (1 << 17)) { |
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.
It is just an arbitrary constant to make the existing tests pass. It should be larger than 2**16
, otherwise we will need to make input data for two tests much larger. I do not think that the real code needs more than 0 or 1. I have no good name for this. The Python code does not need this, as it does not have such limitation and is not vulnerable to this issue (but it is less efficient).
This is the part of this PR that I have the greatest doubts. It can make the unpickler to reject an unusual data that is accepted now, although such data never produced by the pickler (it can still be unpickled with the Python implementation).
I can make the C code more similar to the Python code (as a fallback). But this will add a lot of complexity, and I do not know whether there is a use case for this at all. It may be a hypothetical problem that never occurs in real world, except some artificial tests.
Note that this is still on my radar and I do intend to get back to looking at this. |
I have removed the restriction on the memo keys. Now a dict is used for too sparse keys. |
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.
Thanks for your patience on this, sorry for blocking it for so long.
Overall I do think this will be a good change. I do wonder if it'll have negative performance impacts, but as you correctly pointed out I was reading the code a bit wrong so it's NlogN in the huge size case vs today's N. There may be more (up to double-ish) ram consumption temporarily for actual huge data and an additional copy in that case - I don't know if that practically matters or not.
But for that reason and, separately, the addition of the memo_dict
field I think this should just stay in main
and not be backported as a bugfix.
size <<= 1 | ||
yield stop | ||
|
||
def test_too_large_put(self): |
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 add a comment explaining why this and the next test method result in ([], []) being returned no matter what rather than an error when the values are too large? (I suspect readers with a knowledge of the specific pickle protocol may understand, but it isn't obvious otherwise)
Modules/_pickle.c
Outdated
} | ||
} | ||
if (self->memo_dict != NULL) { | ||
PyObject *key = PyLong_FromSsize_t(idx); |
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.
size_t being fed into FromSsize_t could be a problem if size_t idx were large enough to overflow that. is that plausible in this code?
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.
Good catch. Actually, the argument of _Unpickler_MemoGet()
is always a Py_size_t
casted to size_t
, but it is better to always treat it as size_t
here.
Modules/_pickle.c
Outdated
if (s == (size_t)-1) { | ||
return -1; | ||
} | ||
res += s; |
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.
to be pedantic, check for the possibility of overflow first. (practically speaking: I don't think res or s could ever be large enough for that to actually happen) The existing code being replaced already had this "problem" FWIW.
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.
When I wrote code for all these __sizeof__
implementations, I initially added integer overflow checks. But MvL said me to remove them. If we are going to add integer overflow checks, we should add it not only here, but few lines above (for res += self->memo_size * sizeof(PyObject *)
) and in dozens of other places. This is a separate large issue.
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 do wonder if it'll have negative performance impacts
The worst case -- loading a 1GB bytes object -- is now about 2% slower. This may be insignificant, because the 3.13 results lie between them and the ranges almost intersect.
For other tested cases I have not detected any difference.
There may be more (up to double-ish) ram consumption temporarily for actual huge data
This is only possible in extreme case -- loading a large bytes object with extremely fragmented memory (so that realloc()
is inefficient). I did not reproduce such case, so cannot say that it is practically possible. In all other cases this is insignificant. In more realistic scenario you use FRAME, and the total size of objects loaded from the frame data exceed the size of this data.
Modules/_pickle.c
Outdated
} | ||
} | ||
if (self->memo_dict != NULL) { | ||
PyObject *key = PyLong_FromSsize_t(idx); |
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.
Good catch. Actually, the argument of _Unpickler_MemoGet()
is always a Py_size_t
casted to size_t
, but it is better to always treat it as size_t
here.
Modules/_pickle.c
Outdated
if (s == (size_t)-1) { | ||
return -1; | ||
} | ||
res += s; |
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.
When I wrote code for all these __sizeof__
implementations, I initially added integer overflow checks. But MvL said me to remove them. If we are going to add integer overflow checks, we should add it not only here, but few lines above (for res += self->memo_size * sizeof(PyObject *)
) and in dozens of other places. This is a separate large issue.
Loading a small data which does not even involve arbitrary code execution could consume arbitrary large amount of memory. There were three issues:
Now the sparsity of memo indices is limited.Now a dict is used for too sparse memo indices.