I come across a type of vulnerability quite often regarding weak codes being generated. These can be used for OTP, for password resets, etc. Many times, they are 6-8 characters long, with a smaller key space (a-f, 0-9), and case-insensitive. All in all, easily brute-forceable if no rate limiting (or improper rate limiting) is in place.
The question I ask myself each time I find a vulnerability like this is, would attempting to brute force this string be faster if I were to attempt to brute force it randomly or serially. Serially is obviously the easiest way to go about writing some code to automate this task. For a 6-character code, starting at 'aaaaaa', and going through '999999' is a simple loop in Ruby:
[*('a'..'f'), *('0'..'9')].repeated_permutation(6).each do |perm|
p perm.join
end
The benefit to this (at face value) is that you do not need to generate the entire keyspace before attempting to brute force the key. You simply iterate over each subsequent permutation.
Theoretically, the chances of a code being generated equalling 'aaaaaa' and 'f04ab4' are the same. But I am not super concerned about theoretical outcomes here. I am concerned with practical outcomes and telling me the chances a code being generated by a computer being 'aaaaaa' and 'f04ab4' are the same is something I simply don't buy. Theoretically, in a perfect world on paper this might be true. In the real world, that doesn't add up.
So I started playing around with the idea of cracking these codes randomly. At first, I believed I needed to generate the full keyspace before attempting this, and my examples and results below do this. However, I will explain below how I believe you could get away with not generating the full keyspace in order to select from it randomly. All you really need are the number of possible combinations.
Let's see some code for serially cracking a random uuid, truncated to 6 characters:
1 require 'securerandom'
2
3 results = []
4 0.upto(ARGV[0].to_i) do |fdsa|
5 uuid = SecureRandom.uuid
6 uuid = uuid[0..5]
7
8 beg = Time.now
9 en = nil
10 [*('a'..'f'), *('0'..'9')].repeated_permutation(6).each do |perm|
11 if perm.join == uuid
12 en = Time.now
13 break
14 end
15 end
16
17 results << en-beg
18 end
19
20 min = 999 #an impossible number
21 max = 0
22 median = 0
23 average = 0
24
25 results.each do |result|
26 if result < min
27 min = result
28 end
29
30 if result > max
31 max = result
32 end
33
34 average = average + result
35 end
36
37 average = average / results.length.to_f
38
39 sorted = results.sort
40
41 median = (sorted[(results.length - 1)/2] + sorted[results.length / 2]) / 2.0
42
43 p "Max: #{max}"
44 p "Min: #{min}"
45 p "Median: #{median}"
46 p "Average: #{average}"
What is happening should be straight forward. Accepting an integer as the first argument, I iterate this number of times, generating a random uuid, truncating it, then attempting to brute force it serially. Once I have iterated the predefined number of times, storing the time it took to crack the short code in an array, I calculate a few statistics (the max time required to crack a code, the minimum time required, the median time, and the average time.
Here are some results for iterations of different sizes:
--- serial 100 iters ---
"Max: 15.138325266"
"Min: 0.154493369"
"Median: 7.413371801"
"Average: 7.347536151386141"
--- serial 1000 iters ---
"Max: 15.998030706"
"Min: 0.103791957"
"Median: 8.027371177"
"Average: 7.912778955110889"
You can see the results for 100 iterations and 1000 iterations are pretty much in line with each other. The longest time to crack a 6-character truncated uuid took between 15-16 seconds. The minimum time it took was 0.10-0.15 seconds. In both sets, median and average times are relatively in line between 7-8 seconds.
Ok, what about randomly trying to brute force the randomly generated uuid truncated to 6 characters? Here is the code I used for this:
1 require 'securerandom'
2
3 table = File.read('rainbow_table').split("\n")
4 random_table = []
5
6 0.upto(table.length) do |i|
7 random_table << i
8 end
9
10 results = []
11
12 0.upto(ARGV[0].to_i) do |fdsa|
13 random_table = random_table.shuffle
14 uuid = SecureRandom.uuid
15 uuid = uuid[0..5]
16
17 beg = Time.now
18 en = nil
19 random_table.each do |i|
20 if table[i] == uuid
21 en = Time.now
22 break
23 end
24 end
25 results << en-beg
26 end
27
28 min = 999
29 max = 0
30 median = 0
31 average = 0
32
33 results.each do |result|
34 if result < min
35 min = result
36 end
37
38 if result > max
39 max = result
40 end
41
42 average = average + result
43 end
44
45 average = average / results.length.to_f
46
47 sorted = results.sort
48
49 median = (sorted[(results.length - 1)/2] + sorted[results.length/2])/2.0
50
51 p "Max: #{max}"
52 p "Min: #{min}"
53 p "Median: #{median}"
54 p "Average: #{average}"
Very similar to the previous code, except I read in the keyspace from a rainbow table generated separately, and iterate over these values (in a sense). I also create an array called random_table that contains all the possible indexes in the array that hold all the possible keys.
I iterate the number of times defined by ARGV[0], and each iterations, I shuffle the random_table. This shuffle algorithm is built into Ruby and is the Fisher-Yates shuffle algorithm. I also generate my uuid and truncate it on each iteration. Inside this iteration, I iterate over each index (that has now been shuffled) in the random_table array and attempt to brute force the 6-character code by referencing the rainbow table by the index of the current inner iteration. I gather the same statistical information as the serial cracking. These are the results for iterations of 100 and 1000:
--- random 100 iters ---
"Max: 4.961166269"
"Min: 0.130944814"
"Median: 2.494897051"
"Average: 2.452227394910891"
--- random 1000 iters ---
"Max: 5.021977897"
"Min: 0.006075905"
"Median: 2.502533613"
"Average: 2.4856869913756237"
As you can see, there is a massive and consistent difference in the speed of cracking the 6-character uuid when being performed "randomly" versus serially. The max number of second is a full 10 seconds faster than cracking serially. The minimums across both serial and random are in line with each other, but the median and average times cracking randomly are almost a quarter that of the serial median and average times. I think these results speak for themselves.
Now, I will admit that I am also measuring the time it takes to generate the permutation in the serial runs, as opposed to the random runs where the values have been precomputed. I do not believe this has that large of an effect on the times recorded. However, if you wanted to compute the value to test against in the random runs at runtime as opposed to beforehand, you could calculate the value of the string based on the current index it resides at in the keyspace.
Assuming a consistent keyspace (a-z,0-9), which leaves no gaps in between characters available for the possible codes to generate, you can calculate this code on the fly. By using powers of 36, and dividing the current index by 36 to the power of the index (from right to left!) of the character in the code to be generated, you can generate your 'random' code on the fly. This is left to an exercise of the reader. If there are gaps in the characters available for code generation, this is still doable, but you must compensate for the fact that, say, 'f' is 20 characters away from '0', and take this into account when generating the code.