I needed to convert some ePub files to PDF for a project, and found a macOS app that does exactly that. Problem is, the demo version slaps an ugly watermark on everything and limits you to 12 conversions.
This is purely educational, I’m sharing the technical details of how the licensing works, not a complete bypass. The app wraps Calibre’s command-line tools, so you could just use Calibre directly anyway.
Finding the Watermark Logic
The first thing that annoyed me was this watermark slapped on every PDF:
Getting rid of this seemed like a good starting point. I fired up IDA Pro and searched for the watermark string. Found it pretty quickly in the main conversion function:
int __cdecl -[BookObj runcalibre:dstfile:](BookObj *self, SEL a2, id a3, id a4)
{
// variable declarations ...
if ( !byte_100062870 )
{
// setup code ...
if ( (unsigned int)objc_msgSend(v9, "isEqualToString:", CFSTR("pdf")) )
{
v10 = objc_retainAutoreleasedReturnValue(objc_msgSend((id)qword_100062840, "lowercaseString"));
v11 = objc_retainAutoreleasedReturnValue(+[NSString stringWithFormat:](&OBJC_CLASS___NSString, "stringWithFormat:", CFSTR("--paper-size=%@"), v10));
// Here's the license check
if ( ((unsigned __int8)objc_msgSend((id)qword_100062850, "isreg") & 1) != 0 )
v12 = +[NSArray arrayWithObjects:](&OBJC_CLASS___NSArray, "arrayWithObjects:", v6, v7, 0, v17, v18);
else
// Demo version - adds the watermark
v12 = +[NSArray arrayWithObjects:](
&OBJC_CLASS___NSArray,
"arrayWithObjects:",
v6,
CFSTR("--pdf-footer-template"),
CFSTR("\"<div>******ebook converter DEMO Watermarks*******</div>\""),
v7,
0);
// Calls calibre's ebook-convert with the arguments
objc_msgSend(v14, "setLaunchPath:", CFSTR("/Applications/calibre.app/Contents/MacOS/ebook-convert"));
objc_msgSend(v14, "setArguments:", v13);
}
}
return 1;
}
I note that the hardcoded path /Applications/calibre.app/Contents/MacOS/ebook-convert
suggests this is macOS-specific software that depends on Calibre being installed in the standard location, and will not run otherwise.
So the app is basically a GUI wrapper around Calibre’s ebook-convert
tool. The license check is this line:
if ( ((unsigned __int8)objc_msgSend((id)qword_100062850, "isreg") & 1) != 0 )
If isreg
returns true, it runs without the watermark. If false, it adds the --pdf-footer-template
argument that creates the ugly watermark.
The License Check Implementation
I found two classes that implement isreg
methods:
BuyController
(handles purchase logic):
bool __cdecl -[BuyController isreg](BuyController *self, SEL a2)
{
return self->_isreg;
}
RegController
(handles registration UI):
bool __cdecl -[RegController isreg](RegController *self, SEL a2)
{
return !self->_isreg; // Notice the negation
}
The global qword_100062850
points to one of these controllers. After some testing, I found that patching BuyController
’s isreg
method was enough to remove the watermark.
Method Swizzling Solution
Using macOS standard functions it’s quite easy to patch this function to always return a desired value.
To patch both -[BuyController isreg]
method using Objective-C runtime swizzling, we just have to implement a replacement implementation that returns the desired value and then swizzle the instance method for each class.
Below is the full Objective-C code that achieves this. Note that that kind of patch is cleaner and works across app updates:
#import <AppKit/AppKit.h>
#import <Foundation/Foundation.h>
#import <objc/runtime.h>
@interface PatchedLicense : NSObject
- (BOOL)isreg_patched;
@end
@implementation PatchedLicense
+ (void)load {
Class buyController = NSClassFromString(@"BuyController");
if (buyController) {
Method original = class_getInstanceMethod(buyController, @selector(isreg));
Method patched = class_getInstanceMethod(self, @selector(isreg_patched));
method_exchangeImplementations(original, patched);
NSLog(@"[+] Patched BuyController isreg method");
}
}
- (BOOL)isreg_patched {
return YES; // Always return registered
}
@end
method_exchangeImplementations
will swap the implementations of the original class with the methods in PatchedLicense.
The +load
method runs automatically when the dylib is injected, swapping the original isreg
implementation with my patched version.
I compiled this into a dylib and injected it with DYLD_INSERT_LIBRARIES
(on a system with SIP turned off) or with otool.
The PDF watermark disappeared immediately, and the app even prompts as registered :p
Hex Patching Solution
Note that instead of swizzling the function, another patch could be to hex patch the app, i.e., edit the assembly directly:
100008760 | | -[BuyController isreg]:
100008760 | C8 02 00 90 | ADRP X8, #_OBJC_IVAR_$_BuyController._isreg@PAGE; bool _isreg;
100008764 | 08 35 8B B9 | LDRSW X8, [X8,#_OBJC_IVAR_$_BuyController._isreg@PAGEOFF]; bool _isreg;
100008768 | 00 68 68 38 | LDRB W0, [X0,X8]
10000876C | C0 03 5F D6 | RET
On recent macOS systems, binaries are compiled to run on both Intel and ARM - here is the ARM version. For reference, here is the Intel version:
1000066E7 | | -[BuyController isreg]:
1000066E7 | 55 | push rbp
1000066E8 | 48 89 E5 | mov rbp, rsp
1000066EB | 48 8B 05 EE A3 05 00 | mov rax, cs:_OBJC_IVAR_$_BuyController__isreg; char _isreg;
1000066F2 | 0F BE 04 07 | movsx eax, byte ptr [rdi+rax]
1000066F6 | 5D | pop rbp
1000066F7 | C3 | retn
We want to patch the function to return 1, let’s do that in assembly! On ARM:
20 00 80 52 // MOV W0, #0x1
C0 03 5F D6 // RET
This replaces the original 16 bytes (4 instructions) with just 8 bytes. If we want to cleanly NOP the remaining bytes, we can pad with 1F 20 03 D5
twice (NOP instruction in ARM64).
On the Intel side:
B8 01 00 00 00 // mov eax, 1
C3 // ret
To be clean and match the original function size (11 bytes), pad with NOPs 5 times (90).
The Trial Limit Problem
Removing the watermark was just the start. The app still showed this annoying dialog on launch:
Even with the watermark gone, it would eventually lock me out after 12 conversions. Time to dig deeper.
I found the function that displays this dialog by searching for the string “You can try %d ebooks in demo version”:
void __cdecl -[BuyController openbuywindow:](BuyController *self, SEL a2, id a3)
{
void *v4 = objc_retainAutoreleasedReturnValue(-[BuyController window](self, "window", a3));
// Calculate remaining trial uses
LODWORD(v5) = 12 - self->times;
if ( (int)v5 <= 0 )
v5 = 0;
else
v5 = (unsigned int)v5;
v6 = objc_retainAutoreleasedReturnValue(
+[NSString stringWithFormat:](
&OBJC_CLASS___NSString,
"stringWithFormat:",
CFSTR("You can try %d ebooks in demo version, %d left.\n\nif you would like to get the full version, please click 'Buy now' button."),
12,
v5));
objc_msgSend(self->timeslabel, "setStringValue:", v6);
objc_msgSend(NSApp, "runModalForWindow:", v4);
// cleanup code ...
}
The trial limit is tracked in self->times
; it starts at 0 and increments with each conversion. The app shows “12 - times” remaining uses.
A simple patch would be to hook the -[BuyController times]
method to always return 0, the same way as before, but the program would still show this window on launch and I don’t consider this as fully patched software.
Breaking Down the License Validation
The real license check happens in -[BuyController checkkey]
. This is where it gets interesting:
void __cdecl -[BuyController checkkey](BuyController *self, SEL a2)
{
// Get stored license data
-[BuyController getkey2](self, "getkey2");
id ssn = objc_retainAutoreleasedReturnValue(objc_msgSend((id)qword_100062858, "ssn"));
id skey = objc_retainAutoreleasedReturnValue(objc_msgSend((id)qword_100062858, "skey"));
if ( ssn )
{
// Append magic salt to license key
void *salted = objc_retainAutoreleasedReturnValue(objc_msgSend(skey, "stringByAppendingString:", CFSTR("43534")));
void *saltedCopy = objc_msgSend(salted, "copy");
objc_release(salted);
// Compute MD5 hash
id md5Hash = objc_retainAutoreleasedReturnValue(-[BuyController MD5:](self, "MD5:", saltedCopy));
void *hashPrefix = objc_retainAutoreleasedReturnValue(objc_msgSend(md5Hash, "substringWithRange:", 0, 16));
objc_release(md5Hash);
// Check if computed hash matches stored serial number
self->_isreg = (unsigned __int8)objc_msgSend(ssn, "isEqualToString:", hashPrefix);
objc_release(hashPrefix);
objc_release(saltedCopy);
}
self->times = 0;
if ( !self->_isreg )
{
-[BuyController checkver](self, "checkver");
self->times = -[BuyController loadtimes](self, "loadtimes");
-[BuyController openbuywindow:](self, "openbuywindow:", self); // Show trial dialog
}
objc_release(skey);
objc_release(ssn);
}
So the license algorithm is pretty simple:
- Take the license key (skey)
- Append magic salt “43534”
- Compute MD5 hash
- Take first 16 characters
- Compare with stored serial number (ssn)
If they match, _isreg
is set to true. Otherwise, it loads the trial counter and shows the purchase dialog.
Key Generation
Since the algorithm is so straightforward, I wrote a quick Python script to generate valid licenses:
import hashlib
def generate_license_pair(license_key):
salted = license_key + "43534"
md5_hash = hashlib.md5(salted.encode()).hexdigest()
serial_number = md5_hash[:16]
return serial_number
skey = "FAKE-KEY"
print("ssn =", generate_license_pair(skey)) # 7ed501cc30644011
Here is the Objective-C code that achieves the same result using the swizzling method employed previously:
@interface LicenseSpoofer : NSObject
- (NSString *)ssn_patched;
- (NSString *)skey_patched;
@end
@implementation LicenseSpoofer
+ (void)load {
Class regController = NSClassFromString(@"RegController");
if (regController) {
Method originalSSN = class_getInstanceMethod(regController, @selector(ssn));
Method patchedSSN = class_getInstanceMethod(self, @selector(ssn_patched));
if (originalSSN && patchedSSN) {
method_exchangeImplementations(originalSSN, patchedSSN);
}
Method originalSKey = class_getInstanceMethod(regController, @selector(skey));
Method patchedSKey = class_getInstanceMethod(self, @selector(skey_patched));
if (originalSKey && patchedSKey) {
method_exchangeImplementations(originalSKey, patchedSKey);
}
NSLog(@"[+] License data spoofed successfully");
}
}
- (NSString *)ssn_patched {
return @"7ed501cc30644011"; // Generated serial
}
- (NSString *)skey_patched {
return @"FAKE-KEY"; // Our custom key
}
@end
With this new dylib injected, the app is now fully spoofed to be activated!
While writing this post, I still wanted to understand how the times
counter is initialized. It happens here:
self->times = -[BuyController loadtimes](self, "loadtimes");
int __cdecl -[BuyController loadtimes](BuyController *self, SEL a2)
{
NSUserDefaults *v3; // x20
id v4; // x19
id v5; // x21
int v6; // w22
v3 = objc_retainAutoreleasedReturnValue(+[NSUserDefaults standardUserDefaults](&OBJC_CLASS___NSUserDefaults, "standardUserDefaults"));
v4 = objc_retainAutoreleasedReturnValue(-[BuyController GetTimesKey](self, "GetTimesKey"));
v5 = objc_retainAutoreleasedReturnValue(-[NSUserDefaults objectForKey:](v3, "objectForKey:", v4));
v6 = (unsigned int)objc_msgSend(v5, "intValue");
objc_release(v5);
objc_release(v4);
objc_release(v3);
return v6;
}
The number is loaded here:
v4 = objc_retainAutoreleasedReturnValue(-[BuyController GetTimesKey](self, "GetTimesKey"));
id __cdecl -[BuyController GetTimesKey](BuyController *self, SEL a2)
{
return +[NSString stringWithFormat:](&OBJC_CLASS___NSString, "stringWithFormat:", CFSTR("%@times"), CFSTR("pdfdrm"));
}
This program uses NSUserDefaults with key “pdfdrmtimes”. This means that users can manually reset their trial by deleting this preference:
~/Library/Preferences/*.pdfdrm.plist
Or set it to 0:
defaults write *.pdfdrm pdfdrmtimes -int 0
Finally, I wanted to fully understand the activation process, the real one, not the swizzled version. I opened Proxyman, a MITM proxy to capture the traffic. On activation, the app makes the following request:
curl 'https://domain.com/download/api/active200.php?id=serial&pid=91' \
-H 'Host: domain.com' \
-H 'Accept: */*' \
-H 'Accept-Language: en-GB,en;q=0.9' \
-H 'Connection: keep-alive' \
-H 'User-Agent: PDF%20ePub%20DRM%20Removal/300 CFNetwork/3826.500.131 Darwin/24.5.0'
The response is the following, base64 encoded:
c3RhdHVzPTQwCg1tc2c9WW91ciBzbiAqZSoqYSogaXMgd3JvbmcsIGNvcHkgYW5kIHBhc3RlIHNuLCBub3QgdHlwZSBpdCwgdHJ5IGFnYWluLgoN
$ echo "c3RhdHVzPTQwCg1tc2c9WW91ciBzbiAqZSoqYSogaXMgd3JvbmcsIGNvcHkgYW5kIHBhc3RlIHNuLCBub3QgdHlwZSBpdCwgdHJ5IGFnYWluLgoN" | base64 -d
status=40
msg=Your sn *e**a* is wrong, copy and paste sn, not type it, try again.
So, status=40 indicates an invalid serial number.
The API endpoint appears twice in the assembly code, in -[RegController RecheckKey]
and -[RegController activatew:]
:
void __cdecl -[RegController activatew:](RegController *self, SEL a2, id a3)
{
// Variables declarations removed for clarity
// Extract username from UI field
v4 = objc_retainAutoreleasedReturnValue(-[NSTextField stringValue](self->nameedit, "stringValue", a3));
suser = self->suser;
self->suser = v4;
objc_release(suser);
// Extract serial number from UI field
v6 = objc_retainAutoreleasedReturnValue(-[NSTextField stringValue](self->snedit, "stringValue"));
ssn = self->ssn;
self->ssn = v6;
objc_release(ssn);
// Clean up the serial number (trim whitespace)
v8 = self->ssn;
v9 = objc_retainAutoreleasedReturnValue(
+[NSCharacterSet whitespaceAndNewlineCharacterSet](
&OBJC_CLASS___NSCharacterSet,
"whitespaceAndNewlineCharacterSet"));
v10 = objc_retainAutoreleasedReturnValue(-[NSString stringByTrimmingCharactersInSet:](v8, "stringByTrimmingCharactersInSet:", v9));
v11 = self->ssn;
self->ssn = v10;
objc_release(v11);
objc_release(v9);
// Build activation URL with serial number and product ID (91)
v12 = objc_retainAutoreleasedReturnValue(
+[NSString stringWithFormat:](
&OBJC_CLASS___NSString,
"stringWithFormat:",
CFSTR("https://domain.com/download/api/active200.php?id=%@&pid=%@"),
self->ssn,
CFSTR("91")));
// Make HTTP request to activation server
v13 = objc_retainAutoreleasedReturnValue(+[NSURL URLWithString:](&OBJC_CLASS___NSURL, "URLWithString:", v12));
v14 = objc_retainAutoreleasedReturnValue(+[NSData dataWithContentsOfURL:](&OBJC_CLASS___NSData, "dataWithContentsOfURL:", v13));
objc_release(v13);
// Handle network failure
if ( !v14 )
{
-[RegController manaulbtnclick:](self, "manaulbtnclick:", 0); // Manual activation fallback
v16 = 0;
goto LABEL_6;
}
// Process server response and save license file
v15 = objc_msgSend(objc_alloc((Class)&OBJC_CLASS___NSString), "initWithData:encoding:", v14, 4);
-[RegController writekeyfile:](self, "writekeyfile:", v15); // Save to converter.dat
v16 = objc_retainAutoreleasedReturnValue(-[RegController loadkeyfile](self, "loadkeyfile")); // Load it back
objc_release(v15);
// Validate the license data
if ( -[RegController validsn:](self, "validsn:", v16) == 10 ) // 10 = Valid license
{
LABEL_6:
// SUCCESS: Close registration window and show success message
v19 = objc_retainAutoreleasedReturnValue(-[RegController window](self, "window"));
objc_msgSend(v19, "close");
objc_release(v19);
objc_msgSend(NSApp, "stopModal");
// Show success dialog and quit app
v17 = objc_msgSend(objc_alloc((Class)&OBJC_CLASS___NSAlert), "init");
objc_msgSend(v17, "setAlertStyle:", 0);
objc_msgSend(v17, "setMessageText:", self->activatemsg);
v20 = objc_unsafeClaimAutoreleasedReturnValue(objc_msgSend(v17, "addButtonWithTitle:", CFSTR("Quit")));
objc_msgSend(v17, "runModal");
objc_msgSend(NSApp, "terminate:", self);
goto LABEL_7;
}
// FAILURE: Show error message if license data exists but is invalid
if ( objc_msgSend(v16, "length") )
{
v17 = objc_msgSend(objc_alloc((Class)&OBJC_CLASS___NSAlert), "init");
objc_msgSend(v17, "setAlertStyle:", 0);
objc_msgSend(v17, "setMessageText:", self->activatemsg); // Error message from server
v18 = objc_unsafeClaimAutoreleasedReturnValue(objc_msgSend(v17, "addButtonWithTitle:", CFSTR("OK")));
objc_msgSend(v17, "runModal");
LABEL_7:
objc_release(v17);
}
// Cleanup
objc_release(v14);
objc_release(v16);
objc_release(v12);
}
This method handles online license activation by contacting the remote server. The flow is as follows:
- Gets username and serial number from UI fields
- Makes HTTP request to domain.com activation API
- Receives license data from server
- Saves license data to local file (converter.dat)
The license file is then loaded and verified using-[RegController validsn:]
(result == 10); once again, this function could be hooked to always return 10.
I took a quick glance at -[RegController validsn2:]
, which is called by -[RegController validsn:]
.
For reference:
int __cdecl -[RegController validsn2:](RegController *self, SEL a2, id a3)
{
// Variables declarations removed for clarity
int status;
// Retain the license data string
v4 = objc_retain(a3);
// Check if license data has minimum length
if ( (unsigned __int64)objc_msgSend(v4, "length") >= 3 )
{
// Clean up newlines and split by carriage returns
v6 = objc_retainAutoreleasedReturnValue(objc_msgSend(v4, "stringByReplacingOccurrencesOfString:withString:", CFSTR("\n"), &stru_100055D58));
objc_release(v4);
v7 = objc_retainAutoreleasedReturnValue(objc_msgSend(v6, "componentsSeparatedByString:", CFSTR("\r")));
// If only one component, license is malformed
if ( objc_msgSend(v7, "count") == (void *)1 )
{
status = 1; // Invalid license
goto LABEL_26;
}
// Parse license fields from key-value pairs
v8 = objc_retainAutoreleasedReturnValue(-[RegController getkeyvalue:key:](self, "getkeyvalue:key:", v7, CFSTR("status")));
self->status = (unsigned int)objc_msgSend(v8, "intValue");
v9 = objc_retainAutoreleasedReturnValue(-[RegController getkeyvalue:key:](self, "getkeyvalue:key:", v7, CFSTR("orderstat")));
objc_release(v8);
if ( objc_msgSend(v9, "length") )
self->orderstat = (unsigned int)objc_msgSend(v9, "intValue");
// Extract product ID
v10 = (NSString *)objc_retainAutoreleasedReturnValue(-[RegController getkeyvalue:key:](self, "getkeyvalue:key:", v7, CFSTR("pid")));
pid = self->pid;
self->pid = v10;
objc_release(pid);
// Extract license duration
v12 = objc_retainAutoreleasedReturnValue(-[RegController getkeyvalue:key:](self, "getkeyvalue:key:", v7, CFSTR("licenseday")));
objc_release(v9);
self->licenseday = (unsigned int)objc_msgSend(v12, "intValue");
// Extract check date and activation message
v13 = objc_retainAutoreleasedReturnValue(-[RegController getkeyvalue:key:](self, "getkeyvalue:key:", v7, CFSTR("check")));
objc_release(v12);
v14 = (NSString *)objc_retainAutoreleasedReturnValue(-[RegController getkeyvalue:key:](self, "getkeyvalue:key:", v7, CFSTR("msg")));
activatemsg = self->activatemsg;
self->activatemsg = v14;
objc_release(activatemsg);
// Convert check date and get current date
v16 = objc_retainAutoreleasedReturnValue(-[RegController strtodate:](self, "strtodate:", v13));
v17 = objc_retainAutoreleasedReturnValue(+[NSDate date](&OBJC_CLASS___NSDate, "date"));
NSLog(&CFSTR("%@").isa, v13);
// Handle different license types
if ( self->licenseday < 11 )
{
// PERMANENT LICENSE: No time restrictions
v26 = (NSString *)objc_retainAutoreleasedReturnValue(-[RegController getkeyvalue:key:](self, "getkeyvalue:key:", v7, CFSTR("sn")));
ssn = self->ssn;
self->ssn = v26;
objc_release(ssn);
v28 = (NSString *)objc_retainAutoreleasedReturnValue(-[RegController getkeyvalue:key:](self, "getkeyvalue:key:", v7, CFSTR("key")));
skey = self->skey;
self->skey = v28;
v18 = v13;
}
else
{
// TRIAL LICENSE: Time-limited validation
v18 = objc_retainAutoreleasedReturnValue(-[RegController getkeyvalue:key:](self, "getkeyvalue:key:", v7, CFSTR("orderdate")));
objc_release(v13);
// Parse order date
v19 = (NSDate *)objc_retainAutoreleasedReturnValue(-[RegController strtodate:](self, "strtodate:", v18));
orderdate = self->orderdate;
self->orderdate = v19;
objc_release(orderdate);
// Calculate days since order (absolute value)
v21 = -[RegController daysBetween:and:](self, "daysBetween:and:", v17, self->orderdate);
v22 = (v21 >= 0) ? v21 : -v21;
// Calculate days since check date (absolute value)
v23 = -[RegController daysBetween:and:](self, "daysBetween:and:", v17, v16);
v24 = (v23 >= 0) ? v23 : -v23;
self->TrialDaysTotal = v24;
// Mark as expired if past license period
if ( v22 > self->licenseday )
self->TrialDaysTotal = 999; // Expiration marker
licenseday = self->licenseday;
// Check if license is still valid or within grace period
if ( v22 <= licenseday )
{
// Within license period, check for grace period expiry
if ( v22 > licenseday + 40 ) // 40-day grace period
{
status = 50; // Expired
goto LABEL_25;
}
}
else
{
// Past license period, check additional conditions
status = 50; // Default to expired
if ( self->orderstat > 49 || v22 > licenseday + 40 )
goto LABEL_25;
}
// License is valid, extract credentials
v30 = (NSString *)objc_retainAutoreleasedReturnValue(-[RegController getkeyvalue:key:](self, "getkeyvalue:key:", v7, CFSTR("sn")));
v31 = self->ssn;
self->ssn = v30;
objc_release(v31);
v32 = (NSString *)objc_retainAutoreleasedReturnValue(-[RegController getkeyvalue:key:](self, "getkeyvalue:key:", v7, CFSTR("key")));
skey = self->skey;
self->skey = v32;
}
objc_release(skey);
status = self->status; // Return the parsed status
LABEL_25:
objc_release(v17);
objc_release(v16);
objc_release(v18);
LABEL_26:
objc_release(v7);
v4 = v6;
goto LABEL_27;
}
status = 1; // Invalid/malformed license
LABEL_27:
objc_release(v4);
return status;
}
Since the network request failed (I don’t have a valid license), I can only assume the license file format based on the analysis of this function. This method implements a sophisticated license validation system that handles both permanent licenses and time-limited trials. The license data uses a key-value format separated by carriage returns (\r), containing fields such as: status, orderstat, pid, licenseday, check, msg, orderdate, sn, key It appears that there are two license types:
- Permanent License (licenseday < 11)
- No expiration checks
- Immediate acceptance
- Trial License (licenseday >= 11)
- Uses both orderdate and check dates
- Computes number of days since order
- Adds 40-day grace period
- Marks as expired (TrialDaysTotal = 999) if exceeded
The function then returns:
- 1: Invalid/malformed license
- 10: Valid license (from previous analysis)
- 50: Expired or blocked license
I haven’t bothered creating a fake license file since the previous activation method is quite straightforward.
Conclusion
This macOS app’s licensing system is surprisingly simple to bypass. The watermark removal requires just patching a single isreg
method, while full activation can be achieved through method swizzling to spoof license credentials. The MD5-based validation with a hardcoded salt makes key generation trivial. For educational purposes, this demonstrates common weaknesses in client-side license enforcement, but honestly, just use Calibre directly instead :p