Skip to content

Instantly share code, notes, and snippets.

@appgoblin
Forked from nocturn9x/mizip_util.py
Created June 5, 2025 20:56
Show Gist options
  • Select an option

  • Save appgoblin/e1e4207741d64dba73f68b9bf1d622ba to your computer and use it in GitHub Desktop.

Select an option

Save appgoblin/e1e4207741d64dba73f68b9bf1d622ba to your computer and use it in GitHub Desktop.
A simple tool to tinker with MiZip Mifare tags. It can generate sector(s) decryption keys as well as modified dump files to alter a tag's balance. If you know what you're doing, you can even use this tool to transform any Mifare 1K/4K tag (and probably others using the same scheme) into a "MiZip-compatible" tag recognizable by vending machines
#!/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}")
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment