[Crypto] Why authenticated encryption and MAC is so important

Remember serial number for Windows (or 'product key'). 5 groups of 5 characters.

When you sell a software and want to give a user a set enabled options/features, you use some sort of license key, license code, serial number, and so on.

Clearly, a user will try to modify characters or flip some bits with the hope that other features/options will be enabled. Or expiration date will be extended. A hacker lives in every soul.

This is not as crazy as it sounds. Let's have a case: a license code has 16 bytes, it's encrypted by AES. A secret key is not known to attacker/user. Decrypted code contains serial number (32 bits), expiration date (UNIX timestamp) and options enabled (32 bits).

#!/usr/bin/env python3

import zlib, struct, hexdump, os, time

# pip install pycryptodome
from Crypto.Cipher import AES

from datetime import datetime

cnt=0
current_time=int(time.time())

def check_lic_code(code):
    # key is unknown to attacker:
    key=b"\x12\x34\x56\x78\x12\x34\x56\x78\x12\x34\x56\x78\x12\x34\x56\x78"
    # decrypt it
    IV=b"\x00"*16
    cipher=AES.new(key, AES.MODE_CBC, IV)
    plaintext=cipher.decrypt(code)
    body=plaintext[0:12]
    exp_date=struct.unpack("<I", plaintext[4:8])[0]
    # is expiration date is in past?
    if exp_date<current_time:
        return
    print ("lic.code OK")
    print ("calls", cnt)
    print ("ciphertext:")
    hexdump.hexdump(code)
    print ("plaintext:")
    hexdump.hexdump(plaintext)
    SN=struct.unpack("<I", plaintext[0:4])[0]
    features=struct.unpack("<I", plaintext[8:12])[0]
    print ("SN", hex(SN))
    print ("features", hex(features))
    print ("exp_date", datetime.fromtimestamp(exp_date))
    exit(0)

while True:
    check_lic_code(os.urandom(16))
    cnt=cnt+1

Only expiration date is checked, against current time. To break this 'protection', we generate random codes, without even knowing AES key. Without even knowing that AES is used. After just couple of attempts, we found the correct code:

lic.code OK
calls 1
ciphertext:
00000000: B1 8A 84 A6 52 3F 38 E5  AD 48 41 54 9B 41 5B 37  ....R?8..HAT.A[7
plaintext:
00000000: 1B 7A 71 B7 66 F5 D8 9D  26 C7 9C 9F 55 41 16 F9  .zq.f...&...UA..
SN 0xb7717a1b
features 0x9f9cc726
exp_date 2053-12-02 00:38:30

Such a 'protection' can be cracked even if manually entering random license codes.

OK, you will say, some check sum is required. Let's try CRC32. Let's put checksum inside of encrypted block, like, CRC32(SN || exp_date || features).

#!/usr/bin/env python3

import zlib, struct, hexdump, os, time

# pip install pycryptodome
from Crypto.Cipher import AES

from datetime import datetime

cnt=0
current_time=int(time.time())

def check_lic_code(code):
    # key is unknown to attacker:
    key=b"\x12\x34\x56\x78\x12\x34\x56\x78\x12\x34\x56\x78\x12\x34\x56\x78"
    # decrypt it
    IV=b"\x00"*16
    cipher=AES.new(key, AES.MODE_CBC, IV)
    plaintext=cipher.decrypt(code)
    body=plaintext[0:12]
    exp_date=struct.unpack("<I", plaintext[4:8])[0]
    # is expiration date is in past?
    if exp_date<current_time:
        return
    crc32_must_be=struct.unpack("<I", plaintext[12:])[0]
    crc32_we_got=zlib.crc32(body)
    if (crc32_must_be)==(crc32_we_got):
        print ("lic.code OK")
        print ("crc32 (both)", hex(crc32_must_be))
        print ("calls", cnt)
        print ("ciphertext:")
        hexdump.hexdump(code)
        print ("plaintext:")
        hexdump.hexdump(plaintext)
        SN=struct.unpack("<I", plaintext[0:4])[0]
        features=struct.unpack("<I", plaintext[8:12])[0]
        print ("SN", hex(SN))
        print ("features", hex(features))
        print ("exp_date", datetime.fromtimestamp(exp_date))
        exit(0)

while True:
    check_lic_code(os.urandom(16))
    cnt=cnt+1

I continue sending random codes. And guess what? I finally found the one. Which is, after decryption, has a correct CRC32 checksum:

lic.code OK
crc32 (both) 0xaa44dd9b
calls 929558176
ciphertext:
00000000: 67 08 14 E6 3E D2 42 E9  FF 6F 08 59 82 B1 41 65  g...>.B..o.Y..Ae
plaintext:
00000000: ED 9E 46 D3 8B 51 8B FC  0A 2F 19 91 9B DD 44 AA  ..F..Q.../....D.
SN 0xd3469eed
features 0x91192f0a
exp_date 2104-04-07 07:20:11
['/home/i/dotfiles/bin/my_time.py', 'python3', './1.py']
seconds:  9828
or:  2h43m48s

Not fast, but doable: I spent ~3 hours and ~10**9 calls to that function. So if a software product has a similar license code check, and this check is resided in some Windows DLL, and you can call that function many times, you could finally find a correct code. Even more -- after several tries, you'll find several codes with different features enabled and different expiration date.

And this is slow Python. Pure C/C++ code will be faster, of course. This is even doable via internet connection. This is like fuzzing (fuzz testing).

OK, you'll say, here you will need to use some serious cryptography -- hash function or so. And maybe add a magic cookie in header, like 0xCAFEBABE. But this has its downside: in 1990s, license codes were pronounced via phone and typed manually. During that times, license codes had to be short. But today, one kilobyte license file is not a problem, of course.

Moral of the story -- using even strong encryption like AES with secret key may not be enough. A popular CRC32 may not be enough. Usually, MAC is a solution: (keyed) hash function like SHA2. HMAC is a popular choice today.

BTW, first versions of SSH (mid-1990s) used CRC32 for checksum. But it was a temporal ad hoc solution and quickly removed.

(the post first published at 20231024.)


List of my other blog posts.

Subscribe to my news feed

Yes, I know about these lousy Disqus ads. Please use adblocker. I would consider to subscribe to 'pro' version of Disqus if the signal/noise ratio in comments would be good enough.