ETOOBUSY 🚀 minimal blogging for the impatient
Cryptopals 29 - Break a SHA-1 keyed MAC using length extension
TL;DR
So far we learned that we need to add authenticators to our messages, so that they cannot be easily manipulated by an attacker, e.g. to gain extended permissions. Such an authenticator also go by the name of MAC (Message Authentication Code).
One way to build a MAC-generating function might be using a hash function together with a secret key. The way we investigate in this challenge is the one from the previous challenge, i.e.:
MAC(message) = SHA1(key || message)
If the adversary does not know the key, they surely can’t generate a valid MAC, right? RIGHT?!?
Well, no so fast!
As anything in cryptography, the devil is in the details. Surely an attacker cannot generate a valid MAC for any random message, but it turns out that they can generate a valid MAC for a new message that is an extension of another message for which they have a valid MAC (e.g. because it was provided by… us).
Let’s assume that we’re a plain user of a website and we get this JSON back after logging in:
{
"Data":
"comment1=cooking%20MCs;userdata=foo;comment2=%20like%20a%20pound%20of%20bacon",
"Authenticator": "60924f646f78bca39e70309a9333cb1669a7919e"
}
This is the situation: we have a message (the Data) and we have a valid MAC for that message (the Authenticator).
Every following time we go to the server, we have to provide the Data and the Authenticator: it will validate them (thanks to the knowledge of the secret key) and grant us permissions based on what’s in the Data.
One characteristic of SHA-1 is that it can be easily used incrementally. In other terms, it’s possible to feed data by chunks, which is very convenient because it allows calculating the digest of very big files without the need to keep them entirely in memory at the same time.
After we have fed all the data, to get the hash value back the algorithm “closes” the stream by appending some data (a padding) and doing the final calculations upon these added data.
In other terms, we have this:
SHA1(message) = SHA1_OPERATIONS(message || padding)
The padding
is no secret, because it’s determined by the length of the
message (in bits) alone. This equivalence can be abused to calculate
another valid MAC starting from the one we have:
SHA1(message || padding || sneaked-data) =
SHA1_OPERATIONS(message || padding || sneaked-data || new-padding)
As the SHA1_OPERATIONS
proceed incrementally, we can use the available
valid MAC to pre-warm our SHA1 calculator by using the MAC directly:
SHA1(message || padding || sneaked-data) =
SHA1_STARTING_FROM(valid-prefix-MAC, sneaked-data)
This new digest, then, is for the forged message message || padding ||
sneaked-data
, which we need to provide to the server together with our
newly calculated SHA1 digest. This will require some trial and error,
because we don’t know beforehand the length of the secret key, so we
cannot be sure about what to put in the padding
part.
Well, this is what iteration is for.
Let’s move on to the code. First, we have extended the My::SHA1
class
from the previous challenge to include:
- a way to easily “warm up” the object, starting from a
starter
value that is just a previously calculated digest:
sub new ($package, %args) {
my $self = bless {
h0 => 0x67452301,
h1 => 0xEFCDAB89,
h2 => 0x98BADCFE,
h3 => 0x10325476,
h4 => 0xC3D2E1F0,
ml => 0, # message length, in bits
left => '', # leftover not reaching 512 bytes
%args, # this can override anything
}, ref($package) || $package;
if (defined(my $starter = delete $self->{starter})) {
$self->@{qw< h0 h1 h2 h3 h4 >} = unpack 'N5', pack 'H*', $starter;
}
return $self;
}
- a way to easily calculate the padding externally, possibly by just providing a length as input (this allows the method to be called as a class method):
sub padding ($self, $length = undef) {
$length //= $self->{ml};
my $l512 = (1 + $length) % 512;
my $n_zeros = 448 - $l512 + ($l512 <= 448 ? 0 : 512);
return join '', "\x80", "\x00" x ($n_zeros / 8),
pack 'N2', $length >> 32, $length & 0xFFFFFFFF;
}
This will come handy in our attack crafting.
The server side is simulated like this:
# This is what we have access to, granted by the "server" and provided
# back to us
my $original_permissions = 'comment1=cooking%20MCs;userdata=foo;' .
'comment2=%20like%20a%20pound%20of%20bacon';
my $original_mac = SHA1_MAC_ps_generate($original_permissions);
sub SHA1_MAC_prefix_secret ($key, $message) {
return My::SHA1->new->add($key, $message)->hex_digest;
}
sub SHA1_MAC_ps_generate ($message) {
return SHA1_MAC_prefix_secret(the_key(), $message);
}
sub SHA1_MAC_ps_check ($message, $authenticator) {
return SHA1_MAC_ps_generate($message) eq $authenticator;
}
sub the_key() { state $key = random_text_word() }
The $original_permissions
and $original_mac
are the Data and the
Authenticator, respectively. The other function that we will be allowed
to use as attackers is SHA1_MAC_ps_check
, which simulates our
attempt to provide a pair of forged $message
and $authenticator
to
sneak in additional permissions.
Now, the attack itself. As we said, we don’t know beforehand the
length of the secret, so we will have to try until we succeed, i.e.
until SHA1_MAC_ps_check
gives us the green light:
# This is what we want to append
my $sneaked_permission = ';admin=true';
# Now we "just" have to try out different secret key lengths
my $original_length = length $original_permissions;
my $key_length = 0;
while ('necessary') {
my $length_so_far = $key_length + $original_length;
my $glue_padding = My::SHA1->padding($length_so_far * 8);
$length_so_far += length $glue_padding;
# Let's "extend" the MAC we got
my $forger =
My::SHA1->new(starter => $original_mac, ml => $length_so_far * 8);
my $forged_mac = $forger->add($sneaked_permission)->hex_digest;
# This is the corresponding full permissions we're forging
my $forged_permissions =
$original_permissions . $glue_padding . $sneaked_permission;
last if SHA1_MAC_ps_check($forged_permissions, $forged_mac);
++$key_length;
}
say "We're in! Secret key length: $key_length "
. "(pssst! key was '@{[ the_key() ]}', but we didn't need it!)";
Depending on our guess about the secret key length, we have a different
assumption about what was the digested $length_so_far
. For the forged
MAC, we have to consider the padding part of the digested data too,
which is why we also add the length of the $glue_padding
.
As anticipated, we’re using our My::SHA1
class to pre-warm the digest
calculation with the previous, valid MAC, as well as our guess on how
much data was digested so far (the initializaiton for the ml
parameters). This gives us $forged_mac
by just adding our
$sneaked_permissions
to the data (calling method hex_digest
will add
the new padding too).
The last thing we have to calculate is the forged Data, which is easy
because we have our guess on the $glue_padding
for any specific
$key_length
we are trying out. This gives us $forged_permissions
.
With our candidate $forged_permissions
and $forged_mac
we try to get
in. If our guess of the $key_length
is right… we’re in!
Stay safe and secure!