Reversing the licensing on a Quantum Scalar i40


Last updated on

Reversing the Licensing on the Quantum Scalar i40 Tape Library

I recently bought a Quantum Scalar i40 tape library and about 35 LTO-6 tapes from a friend, with the intention of using it to store backups of my large (a few dozen terabytes) home file server. I can be a bit forgetful when it comes to things like backups, so having a tape library / autoloader that will automatically rotate tapes for me would be incredibly helpful.

The library has 50 physical slots (25 per magazine) for LTO tapes. 5 of them are reserved for cleaning cartridges. Another 5 of them are reserved as “import/export” slots (since the tape drive has no dedicated “mail slot” like some do, if you want to add a new tape or retrieve a tape from the library, these 5 slots are used as a temporary holding area to insert or remove tapes. The tape drive only partially ejects the magazine in this case, so it doesn’t have to re-inventory the whole thing, but only those 5 slots.)

This leaves 40 slots for your actual media. I assume this is where the name “i40” comes from.

Disclaimer: Anything in this article is to be used for educational and testing purposes only. Distributing or using license keys without authorization, especially for non-hobbyist purposes, may violate terms of service and/or your local laws.

The Problem

I did some poking around in the menus on the front panel of the device, and found out that I only have a license for 25 slots worth of media. The other 15 slots are physically there, but they are locked out in software unless you pay Quantum an exorbitant amount of money. I really don’t believe in paying money to use offline features of a physical device you already own. Additionally, the i40 tape library is out of support by Quantum, so I couldn’t buy a license even if I wanted to!

I did a little bit of research online, and I couldn’t find anything specific on bypassing the licensing, or generating keys for it. So, I decided to take a crack at it on my own.

Background Research

I found a Reddit post from another individual, who owns a Scalar i80 tape library, and was looking into getting root access to the machine. A helpful commenter had experimented with the i2000 libraries and managed to gain root access. In fact, they even managed to generate keys for those models, using a tool that was already present on the filesystem of their libraries. This seemed promising. I definitely would have had a much slower start without this user’s prior research on the i2000.

Getting In

There’s 3 methods of accessing the Quantum i40, that I know of. One is the front panel. One is the Web UI, which mainly mirrors the functions of the front panel in an easier-to-use format. The third is the most interesting one - SSH. As far as I’m aware, this is undocumented, and not intended to be used by end users.

From the other user’s Reddit comment, I found that a potential credential to SSH in with was the username venture and the password v3ntur3. I put the i40 on the network, waited for it to get an IP, and gave it a shot. To my relief, it worked. I had a non-root shell on the Quantum i40. Looks like this thing runs Linux, and it’s got a PowerPC CPU.

Exploration

Unfortunately, the tool that was mentioned on the other user’s blog, lcmcmd, was not present anywhere on my unit. I had to dig deeper.

puma_vpd

Some of my research online found references to a tool called puma_vpd, which would let you change the serial number of the device, in order to use a license you already had for another serial number, on a different device. This tool did exist on my machine, and I played around with it a bit:

$ puma_vpd
Usage: puma_vpd [-fgs?] [-f|--force] [-g|--get] [-s|--set] [-0|--addr0=Eth0 MAC] [-1|--addr1=Eth1 MAC] [-w|--wwn=World Wide Name]
        [-u|--upgrade_nand=True] [-d|--downgrade_nand=True] [-b|--brand=Branding] [-q|--qserial=Quantum Serial] [-t|--servicetag=Service Tag]
        [-l|--permlicenses=Permanent Licenses] [-M|--guiman=GUI Manufacturing] [-m|--scbman=SCB Manufacturing] [-p|--qpart=Quantum LCD Board Part Number]
        [-?|--help] [--usage]

What’s this? --permlicenses? Interesting, this seems like it could be relevant to our goals.

Eventually, I found out I could do puma_vpd -s -l FFFFFFFF, and then puma_vpd -g -l would return the following:

$ puma_vpd -g -l
Installed Permanent Licenses:
[02]    N/A
[04]    N/A
[08]    N/A
[10]    N/A
[20]    N/A
[40]    N/A
[80]    N/A

I messed around with a whole bunch of different arguments to puma_vpd -s -l. At one point I managed to get it to show up with a “90-day Temporary Advanced Reporting License,” but that was all, and that wasn’t even what I wanted anyways. Nothing I did could get the licensing to change on the front panel UI or the web UI.

As far as I can tell, puma_vpd is irrelevant here. We don’t need to mess with it anymore.

The Web UI

In testing the Web UI, I found that you could see current licenses, as well as enter a new license key and find out if it’s valid. A logical first place to look on the path to finding the licensing code, would be the code for the Web UI. Running mount, I found /mnt/application was a SquashFS mounted read-only:

$ mount
rootfs on / type rootfs (rw)
/dev/root on / type ext2 (ro)
proc on /proc type proc (rw,nosuid,nodev,noexec)
sysfs on /sys type sysfs (rw,nosuid,nodev,noexec)
tmpfs on /tmp type tmpfs (rw,nosuid,nodev,mode=777)
tmpfs on /var/run type tmpfs (rw,nosuid,nodev,noexec,mode=755)
tmpfs on /var/tmp type tmpfs (rw,nosuid,nodev,noexec,mode=755)
tmpfs on /var/lock type tmpfs (rw,nosuid,nodev,noexec)
/dev/root on /dev/.static/dev type ext2 (ro)
udev on /dev type tmpfs (rw,mode=755)
tmpfs on /dev/shm type tmpfs (rw)
devpts on /dev/pts type devpts (rw,gid=5,mode=620)
ubi0:Configuration on /mnt/persistence type ubifs (rw,sync)
ubi0:Logging on /var/log type ubifs (rw)
ubi0:Application on /mnt/persistence/images type ubifs (ro)
etc on /etc type unionfs (rw,dirs=/mnt/persistence/etc=rw:/mnt/application/etc=ro:/etc=ro)
/dev/loop0 on /mnt/application type squashfs (ro)
none on /usr/local type unionfs (ro,dirs=/mnt/application/usr/local=rw:/usr/local=rw)
none on /var/www type unionfs (ro,dirs=/mnt/application/www=rw:/var/www=rw)
none on /var/spool/cron/crontabs type unionfs (rw,dirs=/mnt/persistence/crontabs=rw:/var/spool/cron/crontabs=ro)

I had a poke around, and found some promising stuff. Underneath, there was a www/htdocs/ directory which appeared to contain the HTML files for the Web UI frontend. Reading a few of them with more, I quickly found out that these were actually PHP files masquerading as HTML files.

I took a look through the license.htm file, and found calls to functions like get_licensed_features() and set_licensed_feature(). These looked promising. I grepped through the htdocs directory, and the only references to those functions I could find were calls - no actual function definition.

That stumped me for a brief moment, but I had an idea. I took a look at /mnt/application/etc/php/php.ini, and found, yes, indeed, it was loading some suspicious looking PHP extensions:

extension_dir = "/usr/local/lib/php"
extension=libphp_chassis.so
extension=libphp_connectivity.so
extension=libphp_datetime.so
extension=libphp_drives.so
extension=libphp_library.so
extension=libphp_license.so
extension=libphp_misc.so
extension=libphp_network.so
extension=libphp_notification.so
extension=libphp_ras.so
extension=libphp_users.so

libphp_license.so looks promising. How do we get it off of here so we can analyze it?

Aside: Exfiltrating Binaries

I tried a few things to get this binary off of the tape library. There was no ftp, tftp, or curl on the machine. I tried an SCP client pointed at the machine, but it appears the server-side component needed for that to work was not present.

Eventually, I ended up coming up with a rather silly, but working, solution. I just did mount -o bind /tmp /mnt/application/www/htdocs/xml/ (a directory that already existed - remember, /mnt/application is read-only!) I then copied /usr/local/lib/php/libphp_license.so to /tmp, and was able to retrieve it over HTTP from http://tape-library/xml/libphp_license.so.

Any other file exfiltration in this analysis was performed in the same manner - I probably won’t mention it again.

Analyzing the PHP Library

I’ll keep this short, even though the actual analysis took a bit of time for me to figure out what was going on. libphp_license.so did indeed export the PHP functions get_licensed_features() and set_licensed_feature(), but the meat of the code wasn’t actually in there. I eventually figured out that the PHP library was doing some RPC calls to something else somewhere on the system, but it wasn’t immediately obvious what that thing was.

Looking Elsewhere

I took a look at ps:

$ ps
  PID USER       VSZ STAT COMMAND
    1 root      2328 S    init
    2 root         0 SW<  [kthreadd]
    3 root         0 SW<  [ksoftirqd/0]
    4 root         0 SW<  [events/0]
    5 root         0 SW<  [khelper]
    6 root         0 SW<  [kblockd/0]
    7 root         0 SW<  [cqueue]
    8 root         0 SW   [pdflush]
    9 root         0 SW   [pdflush]
   10 root         0 SW<  [kswapd0]
   11 root         0 SW<  [aio/0]
   12 root         0 SW<  [unionfs_siod/0]
   13 root         0 SW<  [nfsiod]
   14 root         0 SW<  [mtdblockd]
   27 root         0 SW<  [rpciod/0]
   82 root      2160 S <  /sbin/udevd --daemon
  860 root         0 SW<  [ubi_bgt0d]
  868 root         0 SW<  [ubifs_bgt0_0]
  874 root         0 SW<  [ubifs_bgt0_2]
  931 root      2328 S    /sbin/syslogd
  979 root      2328 S    /sbin/klogd -n
 1031 root      2332 S    udhcpc -b -p /var/run/udhcpc.eth0.pid -i eth0 -H iosis40
 1051 bin       1972 S    /sbin/portmap
 1061 root      4040 S    /bin/ntpd -c /etc/ntp.conf -g
 1074 root      2436 S    /usr/sbin/inetd /etc/inetd.conf
 1082 root      2756 S    /usr/sbin/dropbear -w
 1092 root      2332 S    /usr/sbin/udhcpd -fS /etc/udhcpd.conf
 1136 root         0 SW<  [loop0]
 2091 root      2864 R    /usr/sbin/dropbear -w
 2320 venture   2332 S    -sh
 2768 venture   2332 R    ps
15086 root      421m S    /usr/local/bin/puma_main
15110 www       6780 S    /usr/local/sbin/lighttpd -f /etc/httpd.conf -D
15113 www      22756 S    /usr/local/bin/php
15114 www      98496 S    /usr/local/bin/php
15115 www      98676 S    /usr/local/bin/php
15124 root      148m S    /usr/local/bin/LocalUI
19920 root     95268 S    /usr/local/sbin/snmpd -c /etc/snmpd.conf -f -A -Lf /var/log/snmpd.log udp:161,udp6:161

The only things I can see here that look out of the ordinary are /usr/local/bin/puma_main and /usr/local/bin/LocalUI. I assume the latter is for the front panel UI - we don’t care about that right now. The former seems interesting. I grabbed it, and it’s an 11-megabyte binary. Clearly this thing has some meat to it.

puma_main

I popped puma_main open in IDA (and waited 15 minutes for it to finish analyzing,) and found that yes, it does have quite a lot of code in it. Not particularly unexpected, given the size. I wasn’t really sure where to start, so I did a string search for license, and found a bunch of stuff that looks promising:

IDA string search showing multiple references to &#x27;License&#x27; in the puma_main binary.

I eventually found a function that appeared to log out messages of license validation successes and failures. Clearly, this was the top-level driver for the license verification/installation. Now I just needed to drill down a bit further to figure out what the code is actually doing.

The Hitachi License

Something that immediately caught my eye was what appeared to be a hardcoded “Hitachi license,” that didn’t check the device serial number. I traced back through the code, and found the global hardcoded string: vcmbc. I entered it into the license field, and it accepted it! Unfortunately, it didn’t actually activate anything. It did put a message in the log stating “Hitachi-specific features” have been enabled. I hope this didn’t break my device in some way.

The Hitachi license was a bust. I’m not sure what it’s actually for, but it looks like we’re going to need to crack the actual licensing algorithm.

Dissecting the Algorithm

I started by using IDA’s pseudo-C decompilation as an aid to get a general idea of what various functions were doing, and rename them (and some of their local variables,) sometimes with the aid of various string constants present in the functions. Unfortunately, IDA’s decompilation isn’t perfect, and it often leaves artifacts that make it clear that something is missing - such as critical computations relying on the values of local variables that appear to have never been assigned.

I had to fall back to looking at the raw PowerPC assembly for this at times. I don’t actually know PowerPC assembly, so I leaned heavily on patterns from other architectures I know, and read documentation to fill in the gaps.

After a bunch of staring at pseudo-C, assembler, asking friends, and even asking my partner (who isn’t a reverse engineer or even a programmer,) I finally, mostly, pieced together what the code was doing.

The Licensing Algorithm

The license string is a 5-character alphanumeric code, consisting of digits 0-9 and lowercase letters a-z, but omitting the letters i, j, o, q, s, and u. The license encodes a feature code, and a compressed version of the tape library’s serial number.

When I provide code examples, they will be in C++, which I have written based on the disassembly and decompilations of the functions in the binary. This should be valid C++ that will compile on its own, if you wish to experiment with it. A lot of the code is messy because I was trying a lot of different things at the time, but it does work!

Serial Number Encoding

Before we deal with the license, the machine’s serial number needs to be encoded into a 32-bit number. This is done differently depending on whether the serial number is 10 digits, or 7 digits. My machine has a 10-digit serial number. Maybe older versions have a shorter serial number, or even different models? I’m not entirely sure, because I only have the one. We save this value for later, for use when validating the license.

The serial number encoding process looks like this:

static inline int map_char(const char c) {
    // Note that i, j, o, q, s, and u are missing.
    static constexpr std::string_view alphabet = "0123456789abcdefghklmnprtvwxyz";

    // lowercase first, then map to index in the alphabet
    const unsigned char uc = static_cast<unsigned char>(c);
    const char lower = static_cast<char>(std::tolower(uc));
    const size_t pos = alphabet.find(lower);

    return (pos == std::string_view::npos)
        ? -1
        : static_cast<int>(pos);
}

// Parses the serial number of the tape library into a uint32_t that is embedded in the license.
// Returns 0 on invalid/unsupported input or if the computed value exceeds 0x7FFFF.
static uint32_t PackSerialNumber(const std::string_view &in) {
    // The original code rejects strings longer than 10.
    if (in.size() > 10) {
        return 0;
    }

    // Map chars to digit indices (base-30). Any bad char => 0.
    std::array<int, 10> a{};
    for (size_t i = 0; i < in.size(); ++i) {
        const int v = map_char(in[i]);

        if (v < 0) {
            return 0;
        }

        a[i] = v;
    }

    const size_t len = in.size();
    uint32_t value;

    if (len == 10) {
        // Use a[3]..a[9] with the same packing as the PPC routine
        int t = a[9] + 10 * a[8];
        int u = a[5] + 10 * a[4] + 100 * a[3];
        int v1 = 12 * a[6] + a[7];

        value = static_cast<uint32_t>((((v1 << 5) + t) << 7) + u);
    }
    else if (len == 7) {
        // Simple decimal-style composition
        value = static_cast<uint32_t>(
            10000 * a[2]
            + 1000 * a[3]
            + 100 * a[4]
            + 10 * a[5]
            + a[6]
        );
    }
    else {
        // Unsupported length path in the original
        return 0;
    }

    return (value <= 0x7FFFFu) ? value : 0u;
}

Base-32 Decoding and Transposition

The first step in licensing validation is to decode the license into a packed 25-bit binary representation. This is done by using performing a base-32 decode, but using a scrambled base-32 table. For whatever reason, we then treat this 25-bit number as a 5x5 bit matrix, and transpose it. Pretty simple, not much to really talk about here.

static constexpr char scrambleCharset[] = "lwdvzyhm4at72enjgb89f@xp5c6q3skr";

// Bitwise helper functions.
static inline uint32_t isBitSet(const uint32_t v, const unsigned int i) {
    return (v >> i) & 1u;
}

static inline void setBitIf(uint32_t& dst, const unsigned i, const uint32_t b) {
    if (b) {
        dst |= (1u << i);
    }
}

// Pack 5 chars (indices 0..31) into 25 bits (big-endian), then transpose the 5x5 bit matrix.
static bool DescrambleLicenseKey(const std::string_view &code, uint32_t& out25) {
    if (code.size() != 5) return false;

    // Pack: v |= idx << (5*i) for i = 4..0 on character i
    uint32_t packed = 0;
    for (int i = 4; i >= 0; --i) {
        const char* p;
        if (p = strchr(scrambleCharset, static_cast<uint8_t>(code.at(i)));
            +p == nullptr) {
            // Not in the table
            return false;
        }

        const int idx = static_cast<int>(p - scrambleCharset);

        packed |= (idx << (5u * static_cast<unsigned int>(i)));
    }

    // Transpose: dest(5*k + j) = src(5*j + k)
    uint32_t decoded = 0;
    for (unsigned j = 0; j < 5; ++j) {
        for (unsigned k = 0; k < 5; ++k) {
            setBitIf(decoded, 5u * k + j, isBitSet(packed, 5u * j + k));
        }
    }

    out25 = decoded;
    return true;
}

Unpacking and S-Boxing

Next, the 25-bit number is unpacked into several parts with some simple bit manipulation, those parts are passed through an S-box, and then the decrypted parts are reassembled into an 8-bit feature code and a 32-bit serial number:

// 64-entry S-box (dword_10934024)
// Used for encrypting the feature byte.
static constexpr std::array<uint8_t, 64> TABLE6 = {
    0, 0x22, 0x1E, 8, 0x31, 4, 0xE, 0x28, 0x27, 0x13, 5, 0x30, 9, 0x3C, 0x1F, 1,
    0x2A, 0xA, 0x12, 0x21, 0x17, 0x2C, 0x33, 0x15, 0xF, 0x39, 0x2D, 0x18, 0x24,
    0x1B, 0xC, 0x36, 0x2F, 0x19, 0xB, 0x2B, 0x14, 0x38, 0x26, 0x1D, 0x1C, 0x25,
    0x34, 0x11, 0x3A, 0xD, 0x16, 0x32, 6, 0x35, 0x3B, 2, 0x20, 0x10, 0x3E, 0x3D,
    0x37, 0x3F, 0x1A, 0x23, 3, 0x2E, 0x29, 7
};

// 256-entry S-box (dword_10933C24)
// Used for encoding the serial number.
static constexpr std::array<uint8_t, 256> TABLE8 = {
    0,0xC0,0x36,   8,0x44,   4,0x90,0x48,0x32,0x6D,0x76,0x4D,0xC9,0x3B,0x3F,0x11,0x1F,0x51,0x63,0xA3,0x16,0x23,0x28, 0xD,0x5A,0xB7,0x9D,0xD4,0x2C,0x55,0xDA,0x1A,
    0x1B,0x5F,0x56,0x2D,0xDC,0xAE,0xB6,0x5B, 0xE,0x29,0x24,0x17,0xA4,0x64,0x52,0x20,0x12,0x3E,0x3A,0x71,0x4C,0x68,0x6C,0x31,0x47,0x83,   5,0x43,   9,0x35,0x9B,   1,
    0x9F, 0xA,0x67,0xCF,0x70,0x25,0x99,0x13,0x74,0xAB,0xA6,0x6B,0x2E,0x62,0x86,0x1C,0x7D,0x40,0xC3,0x94,0xB1,0x79,0x82,0xC5,0x49,0xCB,0x8B,0xAF,0x5E,0x37,0xD1,0xE2,
    0x8D,0xD8,0xB8,0xEA,0xA8,0x80,0x59,0xE0,0x96,0x89,0x7A,0xBD,0xD6,0xB4,0xC1,0x7E,0xA1,0x87,0x92,0xC7,0xCD,0xEE,0xBA,0x75,0xE6,0x98,0xEF,0x8F,0xAD,0xE5,0x50,0xF3,
    0x1D,0x7B,0x7F,0x2F,0x84,0x95,0x38,0x8A,0x14,0x9A,0x26,0x69,0xBB,0x72, 0xB,0xA9,0x5C,0xBE,0xAC,0x53,0x97,0x8C,0x65,0x4A,0xB5,0x81,0x78,0xB9,0x8E,0xA0,0x41,0x60,
    0xD3,0xE8,0xB0,0xCA,0xC4,0x77,0x88,0x9E,0xDF,0x91,0xCC,0xD5,0xD2,0xB2,0xBF,0x93,0xAA,0xE3,0xDE,0xA7,0x6E,0xD0,0xF5,0xE7,0xEC,0xC2,0xF7,0x85,0xC8,0xF0,0x7C,0xF9,
    6,0x21,0x2A,   2,0x4E, 0xF,0xE4,0x33,0xC6,0xDD,0xF2,0x45,0x18,0xA2,0xE9,0x57,0x3C,0x58,0xD7,0xB3,0xF6,0xBC,0xED,0xCE,0xEB,0xF4,0xE1,0x6F,0xFF,0xFB,0xFD,0xD9,
    0x42,0x4F,0x61,0x9C,0x6A,0x46,0xFA,0xF1,0x54,0x39,0x4B,0x5D,0xF8,0xFC,0xFE,0x3D, 0xC,0x27,0x34,0x15,0x19,0xDB,0x73,0xA5,0x30,0x66,0x10,0x1E,   3,0x2B,0x22,   7
};

// Split decoded25 into fields and S-box them
static bool DecodeFeatureAndSerialNumber(const uint32_t decoded25, uint8_t& outFeatureByte, uint32_t& outSerial) {
    const uint32_t v = decoded25; // 25 meaningful bits
    const uint32_t idx6 = (v & 0x3Fu);          // bits 0..5
    const uint32_t idx8a = ((v >> 6) & 0xFFu);  // bits 6..13
    const uint32_t idx8b = ((v >> 14) & 0xFFu); // bits 14..21
    const uint32_t hi10 = ((v >> 22) & 0x3FFu); // bits 22..31 (top bits beyond 25 will be 0)

    outFeatureByte = TABLE6[idx6];
    uint32_t w = 0;

    w |= static_cast<uint32_t>(TABLE8[idx8a]) << 0; // low byte
    w |= static_cast<uint32_t>(TABLE8[idx8b]) << 8; // next byte
    w |= (hi10 << 16);                              // high 10 bits into bits 16..25 (only 3 bits matter!)

    outSerial = w;

    return true;
}

Aside: I initially made a mistake here.

Nobody’s perfect, and that certainly includes me.

When copying the content of TABLE8, I accidentally omitted the last 19 bytes. By some miracle, encoding the first serial number I tried didn’t require any of those bytes, so I didn’t notice.

I scratched my head for about an hour when another serial I tried was producing bogus results. I eventually figured out by writing a function to ensure the table is a full permutation with no holes. I later added this to a static_assert to ensure I didn’t do that again somehow.

Validation

Now we’ve got a feature byte and a serial number word from the license key. Remember that packed serial number we created earlier? Well, that is simply checked for equality with the number decoded from the license key. If that test passes, then it’s for the right machine.

Additionally, the validation routine checks the feature byte. You need a separate license key for each feature you want to enable, you can’t have multiple features per license key.

// Helper function that determines if the (decoded) feature code is a valid feature code.
static bool IsFeatureCodeValid(const uint8_t featureCode) {
    if (featureCode <= 7) { // Capacity On Demand license
        return true;
    }

    // Encryption Key Management license
    if (const bool bit0_clear = (static_cast<uint32_t>(featureCode) & 1u) == 0;
        bit0_clear && featureCode >= 22 && featureCode <= 38) {
        return true;
    }

    // Advanced Reporting license
    if (featureCode == 45 || featureCode == 46) {
        return true;
    }

    return false;
}

I am not 100% sure what values other than 7, 38, 45, and 46 do. I haven’t really tried them. Here’s what I know:

  • 7 - unlocks all capacity (all 40 slots.)
  • 38 - unlocks Encryption Key Management for 2 drives
  • 45 - unlocks Advanced Reporting permanently
  • 46 - unlocks Advanced Reporting for a 90-day trial

Making a Keygen

Good news: Everything here is reversible!

Packing the Serial Number

First, we need to generate that same packed serial number from above, for our tape library’s serial number. The code is exactly the same.

S-Boxing and Encoding

Next, we pack the desired feature code (selected from one of the above codes,) along with the serial number, back into that 25-bit number, after S-boxing the separate parts with the inverse of the previous S-boxes:

// Inverses of the S-boxes.
static constexpr std::array<uint8_t, 64> INV6 = [] {
    std::array<uint8_t, 64> inv{};

    for (uint32_t i = 0; i < 64; i++) {
        inv[TABLE6[i]] = static_cast<uint8_t>(i);
    }

    return inv;
}();

static constexpr std::array<uint8_t, 256> INV8 = [] {
    std::array<uint8_t, 256> inv{};
    for (uint32_t i = 0; i < 256; i++) {
        inv[TABLE8[i]] = static_cast<uint8_t>(i);
    }
    return inv;
}();

// From (featureByte, word) -> 25-bit decoded value (before scrambling)
static uint32_t EncodeFeatureAndSerialNumber(const uint8_t featureByte, const uint32_t serialNumber) {
    // invert TABLE6 and TABLE8
    const uint32_t idx6 = INV6[featureByte];

    const uint32_t lo = serialNumber & 0xFFu;
    const uint32_t mid = (serialNumber >> 8) & 0xFFu;
    const uint32_t hi10 = (serialNumber >> 16) & 0x3FFu;

    const uint32_t idx8a = INV8[lo];   // 0..255
    const uint32_t idx8b = INV8[mid];  // 0..255

    // bits 0..5   = idx6
    // bits 6..13  = idx8a
    // bits 14..21 = idx8b
    // bits 22..31 = hi10 (only low 10 bits are meaningful)
    uint32_t v = 0;

    v |= (idx6 & 0x3Fu) << 0;
    v |= (idx8a & 0xFFu) << 6;
    v |= (idx8b & 0xFFu) << 14;
    v |= (hi10 & 0x3FFu) << 22;

    return v;
}

Transposing and Base-30 Encoding

We once again treat the number as a 5x5-bit matrix, and transpose it. We then base-30 encode that number, to get the final license key:

// From decoded25 -> scrambled 5-character key
static bool ScrambleLicenseKey(const uint32_t decoded25, std::string& outKey) {
    // Transpose is its own inverse for a 5x5 bit matrix:
    // if forward did: dest(5*k+j) = src(5*j+k)
    // we do the SAME again to get back to packed chunks.
    uint32_t packed = 0;
    for (unsigned j = 0; j < 5; ++j) {
        for (unsigned k = 0; k < 5; ++k) {
            setBitIf(packed, 5u * j + k, isBitSet(decoded25, 5u * k + j));
        }
    }

    // Now extract 5 groups of 5 bits at positions 5*i..5*i+4 and map to characters.
    outKey.resize(5);

    for (int i = 4; i >= 0; --i) {
        const uint32_t idx = (packed >> (5u * static_cast<unsigned>(i))) & 0x1Fu; // 0..31

        outKey[static_cast<size_t>(i)] = scrambleCharset[idx];
    }

    return true;
}

Putting it All Together

This is pretty self-explanatory - we pack our machine’s serial number, and then encode a key for each of the 3 features we can license:

// (featureByte, serialNumber) -> 5-char scrambled key
static bool EncodeAndScramble(const uint8_t featureByte, const uint32_t serialNumber, std::string& outKey) {
    const uint32_t decoded25 = EncodeFeatureAndSerialNumber(featureByte, serialNumber);

    return ScrambleLicenseKey(decoded25, outKey);
}

int main() {
    const uint32_t parsedSerial = PackSerialNumber("0000000000");

    std::string serialCode;

    // Capacity
    EncodeAndScramble(7, parsedSerial, serialCode);
    std::cout << "CoD License Key: " << serialCode << std::endl;

    // AR
    EncodeAndScramble(45, parsedSerial, serialCode);
    std::cout << "Advanced Reporting License Key: " << serialCode << std::endl;

    // EKM
    EncodeAndScramble(38, parsedSerial, serialCode);
    std::cout << "Encryption Key Management License Key: " << serialCode << std::endl;

    return 0;
}

These licenses can be entered on the front panel or in the Web UI, to unlock the full capacity of the device you paid for.

Conclusion

This whole project took me about a day and a half. When I really care about something like this, I can easily focus on it for hours at a time. It was a ton of fun - I quite enjoy the feeling of success when I’ve tried what feels like a hundred times, and finally the 101st time, I succeed and get that “License Valid!” message. I hope you learned something from this, and I hope if you have one of these machines and don’t have $100,000 for a license to use something you already own, that this may be helpful for you.

If you are an individual/hobbyist tinkering with one of these machines, and would like some guidance or other help, feel free to drop me an e-mail!