-------------The Print Shop------------ A 4am crack 2024-12-25 updated 2024-12-27 Name: The Print Shop Genre: graphics Year: 1984 Credits: David Balsam, Martin Kahn Publisher: Broderbund Software Platform: Apple ][ (48K) Media: 5.25-inch disk Sides: 2 OS: custom Previous cracks: Mr. Krak-Man, many others _________________________________ /\ \ \_| ESTRAGON | | I can't go on like this. | | VLADIMIR | | That's what you think. | | ESTRAGON: | | If we parted? That might be | | better for us. | | VLADIMIR | | We'll hang ourselves | | tomorrow. (Pause) Unless | | Godot comes. | | ESTRAGON | | And if he comes? | | VLADIMIR | | We'll be saved. | | | | -- "Waiting for Godot" | | ____________________________|_ \_/______________________________/ ...............CHAPTER 0............... IN WHICH VARIOUS AUTOMATED TOOLS FAIL IN INTERESTING WAYS COPYA read error on last pass Locksmith Fast Disk Backup unable to read track $22 copy displays title screen then hangs on main menu EDD 4 bit copy (no sync, no count) read error on track $22 copy displays title screen then hangs on main menu Copy ][+ nibble editor track $22 is quite unusual, with repeated sequences of $D4 $D5 $DE $D4 and other nibbles, with and without timing bits --v-- COPY ][ PLUS BIT COPY PROGRAM 8.4 (C) 1982-9 CENTRAL POINT SOFTWARE, INC. --------------------------------------- TRACK: 22 START: 1800 LENGTH: 3DFF 2020: FF+FF+FF+D4 D5 DE D4 AA VIEW 2028: AA F5 AA+FF+DA+9A BB DA 2030: 95 AA FD D5 FF FF D4 D5 2038: DE D4 AB AA F5 AA FF+FF+ 2040: D4 D5 DE D4 AB AB F5 AA+ <-2040 2048: A6+FF+A6 AE F6 A5+AA EF 2050: B5+FF+FF+FF+D4 D5 DE D4 2058: AA AF F5 AA FF+C9+D4 D5 FIND: 2060: DE D4 AB AE F5 AA FF+FF+ D4 D5 DE --^-- Disk Edit track 0 has a custom bootloader track $11 has a DOS 3.3-style disk catalog but it contains only fake files that display a copyright message no way to read track $22 (no sectors) Why didn't COPYA work? non-standard structure on track $22 Why didn't Locksmith FDB / EDD work? presumably there is some runtime protection check that triggers after displaying the main menu, which checks the structure of track $22 Next steps: 1. find runtime protection check 2. disable it 3. declare victory (*) (*) go on ...............CHAPTER 1............... IN WHICH WE FIND THE CODE THAT READS THE TRACK THAT CAN'T BE READ BY COPIERS _______ / ___ | ince my copy goes down a | (__ \_| different code path than '.___`-. the original, I'm guessing |`\____) | there is a runtime check |_______.' somewhere. One thing that all on-disk checks have in common is they need to turn on the drive motor by accessing a specific address in the $C0xx range. For slot 6, it's $C0E9, but to allow disks to boot from any slot, developers usually use code like this: LDX LDA $C089,X There's nothing that says where the slot number has to be, although the disk controller ROM routine uses zero page $2B and lots of disks just reuse that. There's also nothing that says you have to use the X-register as the index, or that you must use the accumulator as the load register. But most RWTS code does, out of convention I suppose (or possibly fear of messing up such low-level code in subtle ways). Also, since developers don't actually want people finding their protection- related code, they may try to encrypt it or obfuscate it to prevent people from finding it. But eventually, the code must exist and the code must run, and it must run on my machine, and I have the final say on what my machine does or does not do. But sometimes you get lucky. Turning to my trusty Disk Edit sector editor, I search the non-working copy for "BD 89 C0", which is the opcode sequence for "LDA $C089,X". [Disk Edit] ["F"ind] ["H"ex] ["BD 89 C0"] --v-- ------------ DISK SEARCH -------------- $00/$08-$09 $00/$08-$3E $00/$0A-$E5 $00/$0F-$16 $00/$0F-$6C $07/$0A-$6A $10/$06-$26 $10/$0D-$4F --^-- The matches on track $00 are part of an in-app backup system (more on this later). The match on track $07 seems uninteresting. T10,S0D is part of a late-loaded but standard DOS 3.3 RWTS. The other match on track $10 might be my jackpot. The protection routine appears to start at offset $19: --v-- T10,S06 ----------- DISASSEMBLY MODE ---------- ; initialize an entire page of memory ; at $BB00 0019:A0 00 LDY #$00 001B:A9 FF LDA #$FF 001D:99 00 BB STA $BB00,Y 0020:C8 INY 0021:D0 FA BNE $001D ; turn on drive motor manually (always ; suspicious) 0023:AE F8 05 LDX $05F8 0026:BD 89 C0 LDA $C089,X ; look for that nibble sequence I saw ; in the nibble editor: $D4 $D5 $DE $D4 0029:BD 8C C0 LDA $C08C,X 002C:10 FB BPL $0029 002E:C9 D4 CMP #$D4 0030:D0 F1 BNE $0023 ; This subroutine just does the LDA/BPL ; loop to get the next nibble. But it ; tells me that this code is probably ; executing from $B6xx in memory. 0032:20 E5 B6 JSR $B6E5 0035:C9 D5 CMP #$D5 0037:D0 F5 BNE $002E 0039:20 E5 B6 JSR $B6E5 003C:C9 DE CMP #$DE 003E:D0 F5 BNE $0035 0040:20 E5 B6 JSR $B6E5 0043:C9 D4 CMP #$D4 0045:D0 F5 BNE $003C 0047:EA NOP ; get a 4-and-4 encoded value from the ; next two nibbles 0048:BD 8C C0 LDA $C08C,X 004B:10 FB BPL $0048 004D:2A ROL 004E:85 26 STA $26 0050:BD 8C C0 LDA $C08C,X 0053:10 FB BPL $0050 0055:25 26 AND $26 ; transfer value into Y register 0057:A8 TAY ; continue below 0058:4C B5 B6 JMP $B6B5 . . . ; now check for an epilogue of sorts, ; the two-nibble sequence $F5 $AA 00B5:20 E5 B6 JSR $B6E5 00B8:C9 F5 CMP #$F5 00BA:D0 20 BNE $00DC 00BC:20 E5 B6 JSR $B6E5 00BF:C9 AA CMP #$AA 00C1:D0 19 BNE $00DC ; Using the 4-and-4 encoded value as ; a lookup into the page we initialized ; earlier, see if we've seen this value ; already. (We initialized all 256 ; addresses with #$FF.) 00C3:B9 00 BB LDA $BB00,Y ; if we've found this value already, ; skip ahead 00C6:10 14 BPL $00DC ; otherwise mark this value as "found" 00C8:A9 00 LDA #$00 00CA:99 00 BB STA $BB00,Y ; loop through the array of "found" ; values and count how many unique ; values we've seen 00CD:AA TAX 00CE:A8 TAY 00CF:B9 00 BB LDA $BB00,Y 00D2:30 01 BMI $00D5 00D4:E8 INX 00D5:C8 INY 00D6:D0 F7 BNE $00CF ; have we seen #$A0 unique values yet? 00D8:E0 A0 CPX #$A0 ; yes, we're done 00DA:B0 03 BCS $00DF ; no, loop back and read some more 00DC:4C 23 B6 JMP $B623 ; turn off drive motor and exit 00DF:AE F8 05 LDX $05F8 00E2:BD 88 C0 LDA $C088,X [falls through to subroutine that reads a single nibble, then returns] --^-- Well that certainly explains why my Locksmith Fast Disk Backup copy hung -- it's looking for a nibble sequence ($D4 $D5 $DE $D4) that doesn't exist because it didn't read anything from track $22. But why can't EDD copy this? What's so special about track $22? ...............CHAPTER 2............... IN WHICH WE DECODE THE DATA ON THE TRACK THAT CAN'T BE READ BY COPIERS _____ |_ _| can edit my non-working bit | | copy (created by EDD 4) to | | insert some useful code before _| |_ the protection check starts. |_____| That sector (T10,S06) is in memory at $B600, and my code looks like this: [S6,D1=non-working copy] ; new code B619- 20 EB B6 JSR $B6EB B61C- EA NOP ; original code B61D- 99 00 BB STA $BB00,Y B620- C8 INY B621- D0 FA BNE $B61D ... ; new code -- ; set reset vector to jump to monitor B6EB- A9 69 LDA #$69 B6ED- 8D F2 03 STA $03F2 B6F0- A9 FF LDA #$FF B6F2- 8D F3 03 STA $03F3 B6F5- 49 A5 EOR #$A5 B6F7- 8D F4 03 STA $03F4 ; reproduce original code from $B619 ; and return B6FA- A0 00 LDY #$00 B6FC- A9 FF LDA #$FF B6FE- 60 RTS Now I can press and jump to the monitor while the protection check is running. *C600G ...protection check runs and hangs... Now I'm in the monitor and have unfettered access to all of memory. Let's look at the buffer at $BB00 and see which encoded values are being found and which aren't. *BB00.BBFF BB00- 00 00 00 00 FF FF 00 FF BB08- 00 FF FF FF 00 00 00 00 BB10- 00 00 00 FF FF 00 00 00 BB18- FF 00 FF 00 FF FF 00 FF BB20- 00 00 00 FF 00 00 FF 00 [truncated for brevity and also because it turns out not to be as illuminating as I had hoped] I can also reuse the counting routine (at $B6CA) to find out how many values we're finding. *B6CAL ; new code here B6CA- A9 00 LDA #$00 B6CC- EA NOP ; original code here B6CD- AA TAX B6CE- A8 TAY B6CF- B9 00 BB LDA $BB00,Y B6D2- 30 01 BMI $B6D5 B6D4- E8 INX B6D5- C8 INY B6D6- D0 F7 BNE $B6CF ; new code here -- ; move the count of zero values to A ; and print it as a hex value B6D8- 8A TXA B6D9- 4C DA FD JMP $FDDA *B6CAG 7E The minimum number of unique 4-and-4 encoded values required to pass the protection is #$A0 (160), but we're only seeing #$7E (126). So we're way short. Why are we missing so many values? The first value missing from my bit copy was 5 (in location $BB05), so let's search the original disk for the nibble sequence $AA $AF $F5. That's the 4-and-4 encoded value ($AA $AF), plus the first nibble of the expected epilogue ($F5 $AA). Copy II Plus again: --v-- TRACK: 22 START: 1800 LENGTH: 3DFF 2AC8: D4 AB AA F5 AA AA AA AA VIEW 2AD0: D4 D5 DE D4 AB AB F5 AA 2AD8: FF FF 9A 9A BB DA 95 AB 2AE0: BD D5 FF 9B D4 D5 DE D4 ^^^^^^^^^^^ 2AE8: AA AF F5 AA FF FF A6 AE <-2AE8 ^^^^^ ^^^^^ 2AF0: F6 A5 BA EF B5 FF FF B5 2AF8: D5 DE D4 AB AF F5 AA FF 2B00: FF 9A BB DA 95 D5 BD D5 FIND: 2B08: FF EC D4 D5 DE D4 AE AB AA AF F5 --^-- I highlighted the three relevant sequences, starting at offset $2AE4: - $D4 $D5 $DE $D4 (prologue) - $AA $AF (encoded value) - $F5 $AA (epilogue) In that same screenshot, we can see evidence of several other values: - $AB $AA (2) at offset $2AC9 - $AB $AB (3) at offset $2AD4 - $AE $AB (9) at offset $2B0E This confirms what running the protection code already told me: the original disk contains the group of (prologue)(4-4-encoded value)(epilogue) where the encoded value is 5. Now let's see what my non-working bit copy looks like. [S6,D1=non-working copy] --v-- TRACK: 22 START: 1800 LENGTH: 3DFF 2430: AA FF D4 D5 DE D4 AA AB VIEW ^^^^^^^^^^^ 2438: F5 AA AA AA D4 D5 DE D4 ^^^^^^^^^^^ 2440: AB AA F5 AA AA AA D4 D5 ^^^^^ 2448: DE D4 AB AB F5 AA FF B3 ^^^^^ 2450: A6 AE F6 A5 AA EF B5 FF <-2450 2458: FF A9 AB BD A9 AA BF D6 2460: FF FF D4 D5 DE D4 AB AE ^^^^^^^^^^^ 2468: F5 AA FF FF A6 AE F6 A5 FIND: 2470: BA FF B5 96 FF D4 D5 DE D4 D5 DE ^^^^^^^^ --^-- We can see the track contains lots of groups with 4-and-4 encoded values: - $AA $AB (1) at offset $2436 - $AB $AA (2) at offset $2440 - $AB $AB (3) at offset $244A - $AB $AE (6) at offset $2466 But no group contains $AA $AF (5) anywhere in the track. I am no closer to understanding why. ...............CHAPTER 3............... IN WHICH WE LEARN WHY THE DATA ON THE TRACK THAT CAN'T BE READ BY COPIERS CAN'T BE READ BY COPIERS _____ |_ _| et's return to the original | | disk and examine the | | _ uncopyable track $22 again. _| |__/ | There must be something |________| that explains why the best bit copiers in the world make such a mess of this track. [S6,D1=original disk] --v-- TRACK: 22 START: 1800 LENGTH: 3DFF 2F58: DE D4 AB AA F5 AA AA AA VIEW 2F60: AA D4 D5 DE D4 AB AB F5 2F68: AA FF 9A 9A BB DA 95 AB 2F70: BD D5 80 80 B5 D5 DE D4 ??^^^^^^^^^ 2F78: AA AF F5 AA AA AA FF A6 <-2F78 ^^^^^ 2F80: AE F6 A5 BA EF B5 FF FF 2F88: 9A 9A BB DA 95 EB FD D5 2F90: FF FF 9A 9A BB DA 95 D5 FIND: 2F98: BD D5 FF FF 9A 9A BB DA AA AF F5 --^-- Waaaaait a minute. That can't be right. There's the 4-and-4 encoded value for 5 ($AA $AF, at offset $2F78), and the epilogue after it ($F5 $AA, at offset $2F7A). But look at the prologue before it. There should be $D4 $D5 $DE $D4 at offset $2F74, but the first nibble is missing. Maybe there are two copies of this group and I just found the other one this time? No, this is only instance of $AA $AF $F5 on the track. Side note: I realize the offsets are different, but they're not relevant to this mystery. They depend on where the disk was spinning when Copy II Plus started reading the track this time. That could be anywhere; I'd expect the offsets to change every time I re-read a track. But the nibbles themselves should be the same. That's the data on the disk. Data on the disk shouldn't change every time you read it. That's kind of the point of a storage device. Unless... Oh no. Oh God. Oh God no. There's only one thing you can put on a disk that will change every time you read it: nothing. And by "nothing," I mean "a long sequence of '0' bits." And that's what is on the original disk between each of these groups: nothing. A bit of background. When we say a "0" bit, we really mean "the lack of a magnetic state change." The Disk II drive isn't digital; it's analog. If it doesn't see a state change in a certain period of time, it calls that a "0". If it does see a change, it calls that a "1". But the drive can only tolerate a lack of state changes for so long -- about as long as it takes for two bits to go by. Fun fact(*): this is why you need to use nibbles as an intermediate on-disk format in the first place. No valid nibble contains more than two "0" bits consecutively, when written from most- significant to least-significant bit. (*) not guaranteed, actual fun may vary So what happens when a drive doesn't see a state change after the equivalent of two consecutive "0" bits? The drive thinks the disk is weak, and it starts increasing the amplification to try to compensate, looking for a valid signal. But there is no signal. There is no data. There is just a yawning abyss of nothingness. Eventually, the drive gets desperate and amplifies so much that it starts returning random bits based on ambient noise from the disk motor and the magnetism of the Earth. Seriously. It's trivial to write "0" bits to a disk. You can write whatever you want to a disk; it doesn't need to be what DOS would consider a "valid" nibble. You can write a #$00 nibble like any other 8-bit nibble, and it'll write 8 "0" bits. But when you read that nibble back, the drive can't handle 8 "0" bits in a row, so it will actually return some random bits. Which is why no one does that. Returning random bits doesn't sound very useful for a storage device, but it's exactly what the developer wanted, and that's exactly what this copy protection scheme depends on. Here's why: Bit copiers can't duplicate a long sequence of "0" bits. Why? Because that's not what they see. What they see is some random bits -- the real "0" bits interspersed with phantom "1" bits. So that's what they write to the target disk. Whatever randomness they get when they read the original disk will essentially get "frozen" onto the copy. Now, why does this matter? Let's look at the protection code again. It looks for the prologue $D4 $D5 $DE $D4, then it checks the 4-and-4 encoded value that follows and marks that value as "found" in the buffer at $BB00. But each prologue is preceded by a bitstream that changes every time it's read. Sometimes those random bits will align in such a way that they form two valid nibbles, and the next prologue will be read correctly. Other times, they will only form a partial nibble that is completed by the first few bits of the prologue, so the prologue will be missed. As far as I can tell, the sequence of "0" bits is 18 bits long. I'm not sure the exact length matters. The important part is the randomness they produce. Here's what's on the disk (the $D4 and $D5 are the start of the prologue): /--00--\/--00--\/--D4--\/--D5--\ 0000000000000000001101010011010101 But remember, more than two consecutive "0" bits will form an "abyss" that the floppy drive will fill with randomness. Some of those "0" bits before the prologue will randomly transform into "1" bits, and it'll be a different set every time you read the disk. How does that affect the protection check? Here's one of many possible bitstreams that might come out of the abyss: /--FF--\/--9B--\/--D4--\/--D5--\ 0011111111100110111101010011010101 That's what I saw the first time I read the original disk with the Copy II Plus nibble editor: the abyss coalesces into two full nibbles ($FF $9B), then I read the prologue ($D4 $D5 and so on). Here's another possible bitstream that might come out of the abyss: /--80--\/--80--\/--B5--\ /--D5--\ 1000000010000000101101010011010101 That's what I saw (on the same disk!) the second time I read it, as shown at the beginning of this chapter. This time, the abyss coalesces into two and a half nibbles -- $80, $80, and a few extra bits. Those extra bits combine with the bits that are supposed to be part of the $D4 nibble, but because we're in the middle of a nibble when the $D4 bits start, we end up finishing that nibble ($B5) instead, and the $D4 nibble disappears. The first nibble of the prologue, $D4, gets consumed by the abyss. The second nibble of the prologue, $D5, survives unscathed, but by then it's too late. Without the full prologue, we'll skip this entire group and the 4-and-4 encoded value within it. The protection code requires finding 160 unique 4-and-4 encoded values after a full prologue ($D4 $D5 $DE $D4). The loop at $B6CD counts the number of values it's found by incrementing the X register for every value marked "found" in the buffer at $BB00. At $B6D8, it compares X to #$A0 and branches to $B6DF to return to the caller once it's found enough unique values. It doesn't care which values it finds, or in which order; it only cares about the total count. If you re-read the original disk enough times, each stream of random bits will (eventually) align so the next prologue is (eventually) read correctly and enough different encoded values are (eventually) marked as "found." You'll miss some of the prologues each time you read the track, but you'll (eventually) find them all as the random bits fluctuate. So the check only passes after some number of reads of a nondeterministic bitstream that sometimes (but not always) corrupts the data after it. But bit copiers don't preserve long streams of "0" bits. Instead, they write out whatever phantom "1" bits they find. On a copy, you'll also miss some percentage of prologues -- and thus skip over some percentage of the 4-and-4 encoded values -- each time you read the track. But that percentage will never increase no matter how many times you read it. No more randomness. No more "eventually." God, I hate physical objects. ...............CHAPTER 4............... IN WHICH WE PATCH THE CODE THAT READS THE TRACK THAT CAN'T BE READ BY COPIERS AND DISCOVER WE ARE BEING WATCHED _________ | _ _ | he protection check at |_/ | | \_| $B619 has no "success" or | | "failure" path. It just _| |_ tries forever to find 160 |_____| unique values within this weird region on track $22, then returns without even setting a flag. Completion is success. The obvious patch, then, is to put an "RTS" at the beginning of the routine so it always returns. T10,S06,$19: A0 -> 60 [...reboot...] [...seems to work...] [...try printing a sign...] [...printer outputs reams and reams of garbage...] That is the most obnoxious failure mode I have ever seen. I love it. What's happening here is that the low- level protection check (at $B619) is being bypassed, but elsewhere in the program, it has noticed my subterfuge. This tamper check could be anywhere, and it could be implemented in several ways. The program acts like nothing is wrong until it's time to print. Maybe that's when it runs the tamper check on $B619; maybe it ran it much earlier and just let me think everything was fine until I tried to print. Which is kind of the point. It's the Print Shop. Of course you would ruin a pirate's ability to print. After several attempts, it appears that the entirety of the routine at $B619 is tamper-checked. Putting an "RTS" opcode anywhere will result in the same print failure. Any change to the logic to try to fool it into thinking it's found enough values... print failure. This suggests the program is calculating a checksum on the entire region of memory used by the protection check. I will dig into the actual tamper check later, but next I want to find where $B619 is called. If I can't modify this subroutine, maybe I can modify the code that calls it. On my non-working copy, I changed the code that ends up at $B619 to read the last return address from the stack and break into the monitor: B619- BA TSX B61A- BD 01 01 LDA $0101,X B61D- 8D 00 B6 STA $B600 B620- BD 02 01 LDA $0102,X B623- 8D 01 B6 STA $B601 B626- 4C 59 FF JMP $FF59 Rebooting, I can see where this routine was called by looking at the values I stored in $B600 and $B601. *B600.B601 B600- 08 78 Due to how the Apple II stack works, that address ($7808) is actually 1 byte before the next instruction to be executed after $B619 returns, which means it's 2 bytes after the JSR that called $B619. *7806L 7806- 20 D7 8D JSR $8DD7 This might look surprising because it's not calling $B619, but it's actually fine. If this is the return address on the stack, it means that $8DD7 jumps to $B619 before returning. A JMP does not affect the stack; as far as the CPU is concerned, it's just a continuation of the same subroutine. That means we can start at $8DD7 and trace forwards until execution gets to $B619, which is much easier than using the stack to trace further backwards. *8DD7L 8DD7- EE F5 8D INC $8DF5 8DDA- AD F5 8D LDA $8DF5 8DDD- 29 03 AND #$03 8DDF- D0 F4 BNE $8DD5 $8DD5 is an "RTS", so this will only continue to the protection check every fourth time it's called. The counter starts at #$03 on boot, so the check runs the first time this is routine called but not the next 3 times. Maybe this routine is called from multiple places and they later decided to reduce the disk access? Unclear. ; set up some RWTS parameters 8DE1- A9 01 LDA #$01 8DE3- 8D EA B7 STA $B7EA 8DE6- 8D F8 B7 STA $B7F8 ; some light obfuscation to hide the ; jump address 8DE9- A9 AB LDA #$AB 8DEB- 49 17 EOR #$17 8DED- 8D F4 8D STA $8DF4 8DF0- 6C F3 8D JMP ($8DF3) ; since we've broken into the monitor ; after this code has run, we can see ; the target address: $BCE0 8DF3- E0 BC *BCE0L ; set up some more RWTS parameters BCE0- A9 00 LDA #$00 BCE2- 8D F4 B7 STA $B7F4 BCE5- A9 22 LDA #$22 BCE7- 8D EC B7 STA $B7EC ; seek to track $22 BCEA- 20 FC BC JSR $BCFC ; exit via the protection check at ; $B619 (aha!) BCED- 4C 19 B6 JMP $B619 BCF0- EA NOP BCF1- EA NOP BCF2- EA NOP BCF3- EA NOP BCF4- EA NOP BCF5- EA NOP BCF6- EA NOP BCF7- EA NOP BCF8- EA NOP BCF9- EA NOP BCFA- EA NOP BCFB- EA NOP BCFC- A0 E8 LDY #$E8 BCFE- A9 B7 LDA #$B7 [falls through to the RWTS entry point at $BD00] The obvious patch, then, is to put an "RTS" at $BCE0 so it always returns. T10,S0C,$E0: A9 -> 60 [...reboot...] [...seems to work...] [...try printing a sign...] [...printer outputs reams and reams of garbage...] The protection code at $B619 is tamper- checked. The calling code at $BCE0 is also tamper-checked. I love it. You're not paranoid if they really are out to get you. The obvious patch, then, is to put an "RTS" at $8DD7 so it always returns. Searching for "EE F5 8D" in Disk Edit, I find the routine on disk on T0D,S09. T0D,S09,$DB: EE -> 60 [...reboot...] [...seems to work...] [...try printing a sign...] [...printer prints a sign...] Hallelujah. As elegant as this may seem, my friend qkumba suggested an even smaller patch. The routine at $8DD7 is incrementing a counter then doing AND #$03 and exiting early if the result is not zero. By changing the "AND" to an "ORA", the result will never be zero, and the code with always exit early. 4? No, not a good time. 5? Also not a good time. 8? 12? Actually it's never a good time to run the protection check. But please keep tamper-checking to make sure the protection code is intact. I find this darkly humorous. T0D,S09,$E1: 29 -> 09 And there it is: the rare 1-bit crack. ...............APPENDIX A.............. "NO TOUCHY FISHY" IN WHICH WE FIND THE CODE THAT WATCHES THE CODE THAT CALLS THE CODE THAT READS THE TRACK THAT CAN'T BE READ BY COPIERS __ / \ s I discovered in the / /\ \ most hilarious way, the / ____ \ protection check and _/ / \ \_ its calling code are |____| |____| tamper-checked during printing. Here is the routine that implements that tamper check. It's on disk at T0D,S09,$A6, in memory at $8DA2. ; initialize checksum 8DA2- A9 55 LDA #$55 8DA4- 8D D6 8D STA $8DD6 ; update checksum 8DA7- A9 3C LDA #$3C 8DA9- A2 E0 LDX #$E0 8DAB- A0 0F LDY #$0F 8DAD- 20 BF 8D JSR $8DBF ; and again 8DB0- A9 36 LDA #$36 8DB2- A2 19 LDX #$19 8DB4- A0 3F LDY #$3F 8DB6- 20 BF 8D JSR $8DBF ; and again 8DB9- A9 36 LDA #$36 8DBB- A2 B5 LDX #$B5 8DBD- A0 35 LDY #$35 [execution falls through to $8DBF] ; The checksum update subroutine takes ; the three parameters in X, Y, and A. ; X is the low byte of the address. 8DBF- 86 DE STX $DE ; A is the high byte of the address ; with bit 7 stripped (so minus $80) 8DC1- 0A ASL 8DC2- 38 SEC 8DC3- 6A ROR 8DC4- 85 DF STA $DF ; Y is the length of the bytes that ; contributes to the checksum (minus 1) 8DC6- 98 TYA ; update checksum based on each byte ; in the given region 8DC7- 51 DE EOR ($DE),Y 8DC9- 18 CLC 8DCA- 6D D6 8D ADC $8DD6 8DCD- 8D D6 8D STA $8DD6 8DD0- 88 DEY 8DD1- 10 F3 BPL $8DC6 ; munge the address on the way out ; just because f--- you, that's why 8DD3- 06 DF ASL $DF 8DD5- 60 RTS Revisiting the three calls to this subroutine: ; $10 bytes at $BCE0 (the protection ; check caller that exits via $B619) 8DA7- A9 3C LDA #$3C 8DA9- A2 E0 LDX #$E0 8DAB- A0 0F LDY #$0F ; $40 bytes at $B619 (the first half of ; the protection check) 8DB0- A9 36 LDA #$36 8DB2- A2 19 LDX #$19 8DB4- A0 3F LDY #$3F ; $36 bytes at $B6B5 (the second half ; of the protection check) 8DB9- A9 36 LDA #$36 8DBB- A2 B5 LDX #$B5 8DBD- A0 35 LDY #$35 This tamper check is called a lot. Like a lot a lot. How often? Literally every time any string is printed on screen as part of the program's user interface. By the time you can interact with the main menu, Print Shop has called its protection check once and its tamper check 13 times. ...............APPENDIX B.............. "NO GODS, NO MASTERS" IN WHICH WE FIND THE CODE THAT COPIES THE TRACK THAT CAN'T BE READ BY COPIERS ______ |_ __ \ rint Shop has an incredible | |__) | feature hidden in plain | ___/ sight. If you press Esc _| |_ during boot, it launches an |_____| in-app backup utility that allows you to make one (and only one) protected backup. It then deauthorizes the original disk so it can't be used to make further copies. Both the backup and the deauthorized master can boot and pass the protection check like the original master disk as shipped. The code for the backup utility lives on track $00, like me. --v-- T00,S05 ----------- DISASSEMBLY MODE ---------- 0033:AD 00 C0 LDA $C000 0036:2C 10 C0 BIT $C010 0039:C9 9B CMP #$9B 003B:D0 0A BNE $0047 003D:AD 00 10 LDA $1000 0040:F0 05 BEQ $0047 0042:4C 00 10 JMP $1000 --^-- I can patch that JMP at offset $42 and break to the monitor with the backup utility in memory. [S6,D1=non-working copy] T00,S05,$43: 00 10 -> 59 FF [...reboot...] [...press Esc...] [...breaks to monitor...] *1000L 1000- 4C E1 14 JMP $14E1 *14E1L [...some initialization code...] 14EE- 20 03 10 JSR $1003 *1003L ; seek to track $22 using standard RWTS ; calls (not shown) 1003- 20 52 10 JSR $1052 1006- AE F8 05 LDX $05F8 ; turn on drive motor manually 1009- BD 89 C0 LDA $C089,X 100C- A9 2C LDA #$2C 100E- 85 E0 STA $E0 1010- A0 00 LDY #$00 1012- 88 DEY 1013- D0 04 BNE $1019 1015- C6 E0 DEC $E0 1017- F0 1D BEQ $1036 ; find a nibble sequence, $A5 $DF $D4 1019- BD 8C C0 LDA $C08C,X 101C- 10 FB BPL $1019 101E- C9 A5 CMP #$A5 1020- D0 F0 BNE $1012 1022- BD 8C C0 LDA $C08C,X 1025- 10 FB BPL $1022 1027- C9 DF CMP #$DF 1029- D0 F3 BNE $101E 102B- BD 8C C0 LDA $C08C,X 102E- 10 FB BPL $102B 1030- C9 D4 CMP #$D4 1032- D0 F3 BNE $1027 ; carry bit clear if we found it, ; carry bit set if we didn't 1034- 18 CLC 1035- 24 38 BIT $38 ; turn off drive motor and exit 1037- BD 88 C0 LDA $C088,X 103A- 60 RTS This is a quick check to ensure that the original disk is in the drive, but it's not the full protection check that counts 4-and-4-encoded values within abyss-delimited nibble groups. This check passes on my non-working copy. In fact, this nibble sequence ($A5 $DF $D4) doesn't factor into the protection check at all. Which means there is something else on track $22 that we haven't discovered yet. After the subroutine at $1003 returns, execution continues at $14F1... 14F1- A9 08 LDA #$08 14F3- 20 DC 12 JSR $12DC *12DCL ; again seek to track $22 (not shown) 12DC- 8D EF 12 STA $12EF 12DF- 20 52 10 JSR $1052 ; again turn on the drive motor ; manually 12E2- AE F8 05 LDX $05F8 12E5- BD 89 C0 LDA $C089,X 12E8- A9 04 LDA #$04 12EA- 85 E6 STA $E6 12EC- A0 00 LDY #$00 ; this value was self-modified earlier ; and it looks like it's being used ; as the high byte of an address, so ; ($E7) -> $0800 12EE- A9 08 LDA #$08 12F0- 84 E7 STY $E7 12F2- 85 E8 STA $E8 ; look for that same nibble sequence ; $A5 $DF $D4 12F4- BD 8C C0 LDA $C08C,X 12F7- 10 FB BPL $12F4 12F9- C9 A5 CMP #$A5 12FB- D0 F7 BNE $12F4 12FD- BD 8C C0 LDA $C08C,X 1300- 10 FB BPL $12FD 1302- C9 DF CMP #$DF 1304- D0 F3 BNE $12F9 1306- BD 8C C0 LDA $C08C,X 1309- 10 FB BPL $1306 130B- C9 D4 CMP #$D4 130D- D0 F3 BNE $1302 ; read $100 bytes of 4-and-4-encoded ; data and store it at ($E7), which ; starts at $0800 130F- BD 8C C0 LDA $C08C,X 1312- 10 FB BPL $130F 1314- 2A ROL 1315- 85 E5 STA $E5 1317- BD 8C C0 LDA $C08C,X 131A- 10 FB BPL $1317 131C- 25 E5 AND $E5 131E- 91 E7 STA ($E7),Y 1320- C8 INY 1321- D0 EC BNE $130F 1323- 0E FF FF ASL $FFFF ; a 1-nibble epilogue 1326- BD 8C C0 LDA $C08C,X 1329- 10 FB BPL $1326 132B- C9 CF CMP #$CF 132D- D0 B9 BNE $12E8 ; increment target address and ; decrement counter (initialized to ; #$04 at $12EA, so we're reading $400 ; bytes total into $0800..$0BFF) 132F- E6 E8 INC $E8 1331- C6 E6 DEC $E6 1333- D0 DA BNE $130F ; turn off drive motor and exit 1335- BD 88 C0 LDA $C088,X 1338- 60 RTS What did we just read from track $22? We can put an "RTS" at $14F6 (just after this subroutine is called) and find out. *14F6:60 ; was #$2C *1000G [...read read read...] There are two independent routines, one at $0800 and one at $0900. They do not call each other. The rest of the region is zeroes. I'll start with the routine at $0900. ; set up disk for writing 0900- A6 08 LDX $08 0902- BD 8D C0 LDA $C08D,X 0905- BD 8E C0 LDA $C08E,X 0908- A0 00 LDY #$00 090A- A9 00 LDA #$00 090C- 9D 8F C0 STA $C08F,X 090F- 1D 8C C0 ORA $C08C,X 0912- EA NOP 0913- EA NOP 0914- EA NOP 0915- A9 00 LDA #$00 0917- 20 5F 09 JSR $095F *95FL ; write one nibble 095F- A6 08 LDX $08 0961- EA NOP 0962- EA NOP 0963- EA NOP 0964- 9D 8D C0 STA $C08D,X 0967- DD 8C C0 CMP $C08C,X 096A- 60 RTS Note that we just wrote a #$00 nibble, a.k.a. 8 "0" bits in a row, which as I mentioned earlier is something you can just... do. ; write nibble prologue $D4 $D5 $DE $D4 091A- A9 D4 LDA #$D4 091C- 20 5F 09 JSR $095F 091F- A9 D5 LDA #$D5 0921- 20 5F 09 JSR $095F 0924- A9 DE LDA #$DE 0926- 20 5F 09 JSR $095F 0929- A9 D4 LDA #$D4 092B- 20 5F 09 JSR $095F 092E- 98 TYA 092F- 20 4D 09 JSR $094D *94DL ; write first nibble of a 4-and-4- ; encoded value 094D- 48 PHA 094E- EA NOP 094F- 4A LSR 0950- 09 AA ORA #$AA 0952- 9D 8D C0 STA $C08D,X 0955- DD 8C C0 CMP $C08C,X 0958- 68 PLA 0959- 09 AA ORA #$AA 095B- EA NOP 095C- EA NOP 095D- EA NOP 095E- EA NOP [falls through to $095F to write the second nibble of the 4-and-4-encoded value] Continuing from $0932... ; write the nibble epilogue $F5 $AA 0932- A9 F5 LDA #$F5 0934- 20 5F 09 JSR $095F 0937- A9 AA LDA #$AA 0939- 20 5F 09 JSR $095F ; write another 8 "0" bits in a row 093C- A9 00 LDA #$00 093E- 20 5F 09 JSR $095F ; burn some CPU cycles 0941- 24 00 BIT $00 ; increment value (used to decide what ; 4-and-4-encoded value to write) and ; loop for all 256 values 0943- C8 INY 0944- D0 CF BNE $0915 0946- BD 8E C0 LDA $C08E,X 0949- BD 8C C0 LDA $C08C,X 094C- 60 RTS This routine writes out the protection data on track $22. !!! This routine writes out the protection data on track $22!!! That is... not something you come across every day. Now let's look at the routine at $0800. *800L 0800- 4C 96 08 JMP $0896 *896L ; this is the nibble prologue of the ; region we just read from track $22 ; into $0800..$0BFF 0896- A9 A5 LDA #$A5 0898- 85 00 STA $00 089A- A9 DF LDA #$DF 089C- 85 01 STA $01 089E- A9 D4 LDA #$D4 08A0- 85 02 STA $02 ; this is the epilogue 08A2- A9 CF LDA #$CF 08A4- 85 03 STA $03 08A6- A2 04 LDX #$04 08A8- A9 04 LDA #$04 08AA- 4C 03 08 JMP $0803 *803L 0803- 86 3C STX $3C 0805- 85 3B STA $3B 0807- A0 00 LDY #$00 0809- 84 3A STY $3A ; check write-protect flag 080B- A6 08 LDX $08 080D- BD 8D C0 LDA $C08D,X 0810- BD 8E C0 LDA $C08E,X 0813- 10 02 BPL $0817 ; exit early if disk is write-protected 0815- 38 SEC 0816- 60 RTS ; otherwise set up disk for writing 0817- A0 06 LDY #$06 0819- A9 FF LDA #$FF 081B- 9D 8F C0 STA $C08F,X 081E- 1D 8C C0 ORA $C08C,X 0821- 26 4E ROL $4E 0823- EA NOP ; $0895 is an "RTS" so these are just ; to burn cycles (writing to disk is ; completely CPU-dependent and thus ; must be cycle-accurate) 0824- 20 95 08 JSR $0895 0827- 20 95 08 JSR $0895 082A- 9D 8D C0 STA $C08D,X 082D- 1D 8C C0 ORA $C08C,X 0830- 88 DEY 0831- D0 F0 BNE $0823 0833- EA NOP 0834- A5 00 LDA $00 ; clears carry flag and writes 1 nibble ; (not shown) 0836- 20 8B 08 JSR $088B 0839- A5 01 LDA $01 083B- 20 8B 08 JSR $088B 083E- A5 02 LDA $02 0840- 20 8B 08 JSR $088B That writes the prologue. ; C=0 coming out of $088B, so this is ; an unconditional branch 0843- EA NOP 0844- 90 0C BCC $0852 ... ; write $100 bytes from ($3A), which ; was initialized to $0400 (low byte ; from Y at $0807, high byte from A at ; $08A8) 0852- B1 3A LDA ($3A),Y 0854- 48 PHA 0855- 4A LSR 0856- 09 AA ORA #$AA 0858- 9D 8D C0 STA $C08D,X 085B- DD 8C C0 CMP $C08C,X 085E- C8 INY 085F- D0 07 BNE $0868 ... 0868- 20 95 08 JSR $0895 086B- 68 PLA 086C- 09 AA ORA #$AA 086E- 9D 8D C0 STA $C08D,X 0871- DD 8C C0 CMP $C08C,X ... ; increment page and decrement counter ; (starts at #$04, set from X at $08A6) 0861- E6 3B INC $3B 0863- C6 3C DEC $3C 0865- 4C 6B 08 JMP $086B I won't show the entire execution flow, but this confirms what I suspected: this routine writes the region that we just read from track $22, including this routine. Except it doesn't. Look again at the zero page values. $3C is initially #$04, so we're definitely writing $400 bytes, but not the $400 bytes at $0800. This routine is exactly what you would need in order to do that, but $3B is initially #$04, so the data being written is $0400..$07FF -- the text page, which is being used by the backup utility to display its user interface, progress, swap-disk prompts, &c. Continuing from $14F6... ; restore original byte (I had changed ; this to an "RTS" earlier) *14F6:2C *14F6L 14F6- 2C 10 C0 BIT $C010 14F9- AD FF 0B LDA $0BFF 14FC- F0 62 BEQ $1560 14FE- C9 01 CMP #$01 1500- F0 04 BEQ $1506 1502- C9 02 CMP #$02 1504- F0 38 BEQ $153E $0BFF (the last of the $400 bytes read from track $22) acts as a flag for the user interface. If it's #$00, execution continues. If it's #$01, it halts with this message: THIS DISK HAS ALREADY BEEN USED TO MAKE A BACKUP If $0BFF is #$02, it halts with a different message: THIS DISK CANNOT BE COPIED Most of the backup process is simply copying the unprotected tracks $00-$21, with disk swap prompts. In the final pass, it finally calls the two routines we read from track $22, in memory at $0800 and $0900: ; set internal flag to be checked the ; next time someone tries to use this ; backup utility on this disk ; (1=deauthorized master, so further ; attempts to make a backup will fail ; with an error that this disk has ; already been used to make a backup) 172B- A9 01 LDA #$01 172D- 8D FF 07 STA $07FF ; clobber the writer routines used by ; the backup utility, by writing the ; $A5 $DF $D4 region but followed by ; the text page instead of the original ; code 1730- 20 00 08 JSR $0800 ; rewrite protection data so the disk ; will continue to function when booted ; normally 1733- 20 00 09 JSR $0900 After the backup utility rewrites the protection track on the master disk to turn it into a deauthorized master disk, it prompts you to swap disks one last time and writes the protection track to the protected backup: ; set internal flag to be checked the ; next time someone tries to use this ; backup utility on this disk ; (2=protected backup, so attempts to ; boot this disk and make a backup will ; fail with an error that this disk can ; not be copied) 176F- A9 02 LDA #$02 1771- 8D FF 07 STA $07FF 1774- 20 00 08 JSR $0800 1777- 20 00 09 JSR $0900 Both the backup and the deauthorized master end up with the protection data but no code on track $22; the only difference is the 1-byte flag that leads to different error messages if you try to use either disk to make further copies. But that flag isn't why they can't be used to make further copies. The code that rewrites the protection track (in memory at $0800.. $0BFF) never exists on the backup, and it's been wiped from the deauthorized master. Neither disk can be used to make further copies because they literally don't know how. ...But what if they did? Despite the multi-layered tamper checks on the protection code within the program itself, there are no tamper checks on the backup program's writer routines. After the routines at $0800 and $0900 are read from track $22, we could modify the initialization code at $0896 -- specifically the start address loaded into the accumulator at $08A8. ; new code -- ; set start address in writer routine ; to $0800 172B- A9 08 LDA #$08 172D- 8D A9 08 STA $08A9 ; original code to call writer routines 1730- 20 00 08 JSR $0800 1733- 20 00 09 JSR $0900 Now that $08A9 contains #$08, the subroutine writes out the original protection code at $0800..$0BFF instead of the text screen at $0400..$07FF. That means the master disk will remain a master disk, with all the privileges to make another protected backup and all the code to do so. But wait! There's more! The patch to $08A9 is still active when we write the protection track to the destination disk. The second call to $0800 (at $1774) will again write the original writer code at $0800..$0BFF -- now to our newly created "backup" -- and we end up with two master disks instead of one. No gods, infinite masters. I have not applied this patch on my cracked copy, because there's no point. In fact, the backup utility hangs while searching for the $A5 $DF $D4 nibble sequence on track $22. Despite the allure of a 1-bit crack, I applied two additional patches to ignore the Esc key during boot, so you can't access the backup utility that doesn't work. Never let your pride get in the way of usability. T00,S05,$39: C9 -> A9 T06,S0E,$44: C9 -> A9 Side B is unprotected. Quod erat liberandum. ...............CHANGELOG............... 2024-12-27 - typos 2024-12-25 - initial release A 4am crack No. 3296 ------------------EOF------------------