Join GitHub today
GitHub is home to over 50 million developers working together to host and review code, manage projects, and build software together.
Sign upExposure of HashMap iteration order allows for O(n²) blowup. #36481
Comments
It seems to me like we should basically just revert #33318 for now and use different seeds per map. Ideally we could find a strategy that avoids key reuse but also doesn't regress hashmap creation performance too much, but as @briansmith noted in #33318 there are risks to using multiple correlated keys as well. |
Perhpas we can resolve this by just saying this is outside the threat model. And/or, the |
We'd also have to mandate a sort in JSON serialization, which seems somewhat unreasonable. The fact that simply merging two maps can run into sadness makes it seem to me like we have to do something about this. |
You can't always sort a hashmap because the keys aren't required to be |
A shuffle would do fine though I think. |
@briansmith: Another example would be if a web service validates the JSON it's given key-by-key, by iterating over (key, value) pairs and delegating to a validation function.
It's a footgun. |
We could also use Python's technique to preserve insertion order. It introduces a little more indirection, but the cost doesn't seem to be excessive. This also makes hash map order deterministic, which is a nice usability bonus. Note that tombstones aren't required for determinism, but are required to preserve insertion order through deletions. Either way prevents the attacks given. |
Revisiting #33318 sounds like the reasonable way out. |
HashMap creation needs to be fast in the common case. I think we need a HashMap that defaults to FNV but rehashes with a random SIP key when it gets too much collisions. |
TBH, I'm also curious how alternate approaches (a Both would likely avoid the pathological merging behavior, and the amortized allocations may bring them closer to |
So the problem here is not hard collisions, but rather soft collisions? |
@Veedrac It has some wins too, since the data that needs to be swapped by bucket stealing while inserting is smaller, and iteration is much faster since it's on a mostly compact space. But the indirection costs in lookup may not be acceptable at all. (I made a prototype that demonstrates exactly these differences with a +6% hit to lookup.) Maybe if one moves the hash field to be next to the index, or even special case smaller than 2**32 maps so that they keep 32-bit hashes and 32-bit indices next to each other? |
@bluss The new layout should allow you to lower the occupancy (perhaps cap it at 80%?) without paying much of a hit, since the key/value arrays can safely cap out at 100% - only the index array needs to grow. This should get you some speed back, as well as make pathological insertions a lot rarer. |
@arielb1 Exactly; the first half of the table is ending up with significantly over 100% occupancy. |
Some extra discussion is available in this Reddit thread, including back of the envelope analysis of the asymptotics and commentary on a similar attack mentioned in the SipHash paper. |
the consistent order map prototype is here https://github.com/bluss/ordermap I actually began that not because of this thread but due to curiosity about the new dict representation in Python 3.6. Removal breaks the insert order property. Otherwise you need tombstones (ideas/forks for efficient way of doing that are welcome). Conclusions: iteration can be so much faster than libstd HashMap. Growth too. Lookup is slightly slower (more than the 6% I said above if the map is large and out of cache). It solves the merge pathology. |
That looks pretty awesome at first glance. I don't think like we need to guarantee insertion order iteration, but it would be nice to make it iterate more obviously in not-insertion order - maybe alternate adding to the front and back of the iteration list or something like that? Probably not worth doing if it'd get complex. |
However it looks in this issue, the current HashMap impl has lots of aspects in which its performance is great already. Don't think it's that easy to replace it. |
Oh yeah, absolutely. It's just good to see that there are reasonable routes forward. |
@bluss Safe, stable Rust and only 6% slower insertion normally? Amazing work. |
The particular representation in ordermap maps well to safe rust (using vectors without holes). Making accurate benchmarks is tricky (there are so many different scenarios), I can only ask for help there, and my general summary is in the readme. |
Thanks |
Any progress on this? Do we have a plan for fixing the DoS unsafety? |
There's a bit of history in this thread, so I'll try to summarize the current state bellow. #38368 improved things immensely by detecting degenerate probe lengths and/or forward shifting on insert. There's also a check for minimum occupancy in order to trigger the early resize (so a runtime attack don't become a memory attack). This is somewhat similar to Java's solution. They convert the linked-list collision chain into a balanced-tree when the length crosses a threshold. So there's still a small area that an attacker can exploit, by crafting inputs that cause extra work below those limits. That's really small though and I didn't test if one can actually exploit it. Edit: Also, default hasher gives each hashmap a different seed (clone() retains the seed though), but that's orthogonal to the above. |
Here's a general solution to the problem. after every rehash or clone, reseed the hasher. It will regress the performance of rehash but it will solve the O(n²) problem. Luckily in production code (as a whole) lookups dominate insertions by a large margin so the tradeoff is actually sound. Note: if you end up going this route it might no longer make sense to store the full hashes in the hashtable as they are not going to help when rehashing (they are still going to save comparisons but it is likely that the costs outweigh the benefits). You can instead store the distance from the ideal position in a u8. This will reduce the amount of memory touched until finding the element or terminating the search. |
Storing of 8bits of hash is enough to save comparisons. Actually, I even stored 7bits with great result (using 8bit for other purpose). |
I don't think that's a reasonable tradeoff for a stdlib hasher, growing and cloning the hashmap becomes much much slower. As the slot moving is essentially randomized it'll require a lot of random access and Robin hood adjustments as things go into the new table. The fact that sip isn't the fastest hasher around just makes it even worse. |
@funny-falcon if we store 8-bits of hash for comparisons then we can't bail out early from probe chains based on distance from ideal position. It basically voids the Robin Hood. Or perhaps I misunderstood your suggestion. @arthurprs yes either |
@alkis , I mean, store both 8bits of hash + 8bits distance. So, 16 bits in total. |
@funny-falcon This makes sense |
That's not reasonable, it's too easy to trip on that land-mine. The most reasonable trade-off I've seen thus far is the two array map (OrderMap), that "fixes" the problem by making the degenerate case cheap to a point it can't really be exploited. |
@arthurprs there is a way to "reseed" without requiring support from either
use the pointer to the buckets as additional bytes (this becomes a naturally occurring seed that changes at all the right places):
EDIT: The disadvantage to this solution is that if |
@alkis neat! That makes it general. |
Any takers to implement this approach and close this bug once and forall? :-) |
One has to provide proof that the performance hit isn't huge. I'd not be surprised if this fix yields a ~500% hit on grow/clone and a 20+% hit on lookup when using sip13. |
There is even no need to calculate new hash value each time. // key hash calculated once and stored
let mut state = hash_state.build_hasher();
t.hash(&mut state);
hashvalue = SafeHash.new(state.finish());
// position calculated dependent on storage pointer
// (example for 64bit)
poshash = hashvalue + (table.hash_start as usize);
poshash ^= poshash >> 32
poshash *= 0xdeadcafe71febeef; // or some other "well known" odd number (from Murmur, CityHash, etc)
poshash ^= poshash >> 32; It will be enough for fixing bad case, and there is no need in recalculating SipHash. |
@funny-falcon I do not think this will be very good. The pointer to |
@alkis, just try. It will be just enough to fix bad case. There is no need to be better.
No way. Multiplying by a "random looking" constant and shifting provides really good results. This is a well-known technique (Knuth multiplicative hashing). Simulation (in C): #include <stdio.h>
#include <stdlib.h>
#include <inttypes.h>
uint64_t poshash(uint64_t hv, uint64_t hs) {
uint64_t ph;
ph = hv + hs;
ph ^= ph >> 32;
ph *= 0xdeadcafe71febeeful;
ph ^= ph >> 32;
return ph;
}
int main(int ac, char** av) {
int i, j;
uint64_t hv1, hv2, hv3, hashstart;
uint64_t ph1, ph2, ph3;
uint64_t masks[3] = {0xffff, 0x7fff, 0x3fff};
hv1 = 0x123456789abcdef0ul;
hv2 = 0x98523aed81fcdef0ul;
hv3 = 0x3fa83924dcbcdef0ul;
hashstart = 0x00007fe342e50000ul;
printf("\thv1\thv2\thv3\n");
for (j=0; j<3; j++) {
uint64_t mask = masks[j];
printf("%lx\t%lx\t%lx\t%lx\n",
mask, hv1 & mask, hv2 & mask, hv3 & mask);
}
for (i=0; i<5; i++) {
uint64_t ph1, ph2, ph3;
ph1 = poshash(hv1, hashstart);
ph2 = poshash(hv2, hashstart);
ph3 = poshash(hv3, hashstart);
printf("\t----\t----\t----\n");
for (j=0; j<3; j++) {
uint64_t mask = masks[j];
printf("%lx\t%04lx\t%04lx\t%04lx\n",
mask, ph1 & mask, ph2 & mask, ph3 & mask);
}
hashstart += 0x100ul; /* next allocation */
}
} results:
|
https://en.wikipedia.org/wiki/Universal_hashing#Constructions It is possible to use more sofisticated "multiply-add-shift":
But really there is no need for the usecase. Just "multiply-shift" is more than enough. |
Triage: #56241 (comment) and #56241 (comment) |
Now that #58623 is merged, the |
Veedrac commentedon Sep 14, 2016
Exposing
HashMap
's iteration order can causeO(n²)
blowup even in innocent-looking code without the presence of an attacker. In the presence of an attacker, access to the order of a dictionary allows HashDoS-like attacks with only two requests in common scenarios.Without an attacker
Consider a user with two possibly-disjoint hash maps
The user wants to merge the hash maps, and does so naïvely,
Time for merge when
second_map
is shuffled: 0.4sTime for merge when
second_map
is not shuffled: 40.s (x100 amplification)This effect is noticeably more pronounced when merging with a round robin strategy.
With an attacker
The threat model here is simple. The attacker is able to send JSON to the server. The server parses the JSON into a
HashMap
and through whatever means - an error message including the formatted map or explicit listing of the contents of the map - may reveal the order of the map.The attack on this model requires two requests. The first sends some large JSON to the server
and recieves an ordering
The attacker then sends the first and third quarter of this list in a new JSON object.
Total time without second request: 0.1s
Total time with second request: 200s (x2000 amplification)
Solutions, near-solutions and non-solutions
These solutions should not be considered to be ordered, necessarily disjoint, nor an exhaustive list of options.
Fall back to
BTreeMap
It should be clear that we cannot treat hash maps as a solved problem just because we use
SipHash
. In fact,SipHash
is entirely insufficient to solve the problem. My first suggestion is thus to stop trying to make the hasher secure, and instead fall back toBTreeMap
when nonlinear behaviour is detected.This guarantees a minimum level of performance regardless of the capabilities of the attacker, and allows usage of a faster hashing algorithm by default. Hash maps should get faster by default as a result. This does not prevent having to consider the issue, since the fallback is costly and must be rare, but this is an easier problem than entirely securing the hash map.
Use a hash map without problematic blowup, or less affected by it
Java solves this problem by using a hash map with chaining and converting large buckets to tree maps. This mitigates the impact of degradation, but does not seem to allow using contiguous hash maps by default.
As far as I am aware, the blowup cannot be resolved by moving to another common form of open addressing, although quadratic probing would be significantly less affected by some of these attacks. Chaining alone also defeats the attacks given here, but still requires a secure hash and fails with attackers with more capabilities.
Use different seeds for each hash map
Pull requests #31356 (closed) and #33318 (merged) first proposed incrementing the thread local seed for each hash map. This was later removed when no threat model was seen, but would prevent both attacks listed here.
This still allows attacks when hash maps are reused.
Randomize iteration order
I am not sure how one would randomize iteration order efficiently. However, it should solve the problem unless hashes are exposed through other means.
Ignore the problem
Given that Rust went so far as to use
SipHash
, quadratic blowup on code as simple asfst.extend(snd)
seems too extreme to ignore.