Inside geoWrite – 5: Copy Protection

In the series about the internals of the geoWrite WYSIWYG text editor for the C64, this article discusses the geoWrite copy protection.

Article Series

  1. The Overlay System
  2. Screen Recovery
  3. Font Management
  4. Zero Page
  5. Copy Protection ← this article
  6. Localization
  7. File Format and Pagination
  8. Copy & Paste
  9. Keyboard Handling

GEOS Copy Protection Strategy

GEOS has one of the most notorious copy protection systems. The system disk contains bit patterns that are very hard to reproduce on a stock disk drive. These are checked on every boot in obfuscated code that is used to decrypt the core operating system code. This way, copies of the GEOS boot disk will not boot. Therefore, GEOS always has to be booted from the original disk, so these would break frequently, which is why GEOS came with a second boot disk, and there was a program to get broken boot disks replaced once both failed.

GEOS maker Berkeley Softworks also created several high-profile GEOS applications like geoPublish and geoCalc, which came with a similar copy protection. Even the bundled apps geoWrite, geoSpell and geoMerge have the same protection.

But with apps, it’s more complicated: On a C64 system with a single disk drive, the app needs to be on the same disk as the document that is being worked on, and since it’s a non-starter to make the user edit all their documents on the original application disk, it needs to be possible to have a copy of the app working on the user’s work disk – and still prevent pirated copies of the app from running.

The idea is to link the boot disk and the application through a serial number. On the very first boot, the GEOS system picks a random 16 bit serial number (excluding zero) and stores it on the boot disk as well as on the backup disk – this is called “installing” GEOS. On the first start of a copy-protected application, it verifies that it’s running from the original disk, and if yes, it takes the system’s serial number and stores it – this is called “installing” an application. On subsequent boots, it does not check for the original disk any more, but it only runs if its stored serial number matches the system’s.

As long as the GEOS boot disk cannot be copied, two users (who both bought GEOS) will have different serial numbers, and installed apps from one user won’t work on a different user’s GEOS. And a copy of a not-yet-installed app will refuse to install itself, because it doesn’t run from an original disk.

Code

Apart from this basic protection concept, geoWrite obfuscates what’s going on by encrypting parts of the code, to make it hard to crack the protection.

Let’s walk through the components of the copy protection in the order of what happens on application startup.

Encrypted Record 1

As discussed in part 1 of this series, the GEOS “VLIR” binary consists of 9 so-called records, which are basically individual code files. Record 0 is the main program, and records 1 through 8 get swapped in and out of memory based on what functionality is needed.

When the application gets started, the first thing the record 0 code of geoWrite does is load record 1, which contains initialization code as well as the copy protection.

        lda     #BANK_1  
        jsr     loadCode

All of record 1 is encrypted, so after loading, it decrypts it by XORing every byte with $DE.

        lda     #$EB  
        eor     #$35  
        sta     @2  
        LoadW   r0, MEM_OVERLAY  
        LoadW   r1, -4000  
        ldy     #0  
@1:     lda     (r0),y  
@2 = * + 1  
        eor     #$00  
        sta     (r0),y  
        IncW    r0  
        IncW    r1  
        bne     @1

The decryption constant of $DE gets constructed using $ED XOR $35, for which there is no good reason other than maybe making it harder to search for the value.

After decryption, execution is handed to the record 1 code:

        jmp     MEM_OVERLAY

Installation

The first few instructions of record 1 do things in a way more complicated way than necessary, probably to deter any hackers from looking further:

.,3244  A9 32     LDA #$32  
.,3246  48        PHA  
.,3247  A9 54     LDA #$54  
.,3249  48        PHA  
.,324A  A9 3B     LDA #$3B  
.,324C  85 21     STA $21  
.,324E  A9 6F     LDA #$6F  
.,3250  85 20     STA $20  
.,3252  6C 20 00  JMP ($0020)

The source makes it clear what’s going on:

        lda     #>(@continue-1)  
        pha  
        lda     #<(@continue-1)  
        pha  
        LoadW   r15, initApp  
        jmp     (r15)  
@continue:

It pushes the address of the code following it as a return address (i.e. minus one) on the stack and jumps to initApp using a vector. This is just a convoluted way of calling initApp and continuing with the code below.

initApp does some initialization and calls checkSerialOrInstall. This is the first part of it:

checkSerialOrInstall:  
        lda     serial  
        ora     serial+1                ; does app have a serial?  
        beq     @install                ; no, then install

        lda     #<GetSerialNumber  
        ldx     #>GetSerialNumber  
        jsr     CallRoutine  
        CmpW    serial, r0              ; does the app serial match the system's?  
        beq     @rts                    ; yes, return

        lda     #<txt_serial_mismatch  
        ldy     #>txt_serial_mismatch  
        jsr     showError               ; no, tell the user  
        jsr     swap_userzp  
        jmp     EnterDeskTop            ; and exit

@rts:   rts

(For the meaning of swap_userzp, check out part 4 of this series.)

serial is a 16 bit variable that is part of the record 1 code:

serial:  
        .word   0                       ; not installed

If it is zero, checkSerialOrInstall jumps to the install logic. Otherwise it gets the system’s serial. (It could just call the GetSerialNumber KERNAL API directly, but instead, it calls it by loading its address into registers and calling CallRoutine, so that a reverse engineer has a harder time finding the call.)

If the system’s serial number is the same, the function returns, and the application can start. If no, it shows an error and exits the app.

Let’s look at the installer code. First, it calls executeDiskBlock, which loads a block from disk and runs it:

@install:  
protExecTrack = * + 1  
        lda     #0                      ; protection track (stamped in by build system)  
        sta     r1L  
protExecSector = * + 1  
        lda     #0                      ; protection sector (stamped in by build system)  
        sta     r1H  
        jsr     executeDiskBlock  
        beqx    @ok                     ; no error

This block contains the code to verify that this is an original disk and not a copy. (We will discuss all of this in detail in the next section.) If executeDiskBlock returns with X != 0, this signals a failure, and and geoWrite exits:

        lda     #<txt_copy_protection   ; installing a non-original disk?  
        ldy     #>txt_copy_protection   ; then show a non-informative message  
        bra     showErrorAndExit        ; and exit to deskTop

Otherwise, the installer now knows that it’s an original disk, so it can stamp in the system’s serial into its own code. So after fetching the system’s serial again, it reads the block from disk that is supposed to contain the app’s serial. This block is part of the record 1 file.

@ok:    lda     #<GetSerialNumber  
        ldx     #>GetSerialNumber  
        jsr     CallRoutine             ; get OS serial number to put into app

        MoveW   r0, serial  
        LoadW   r4, diskBlkBuf  
protSerialTrack = * + 1  
        lda     #0                      ; serial track (stamped in by build system)  
        sta     r1L  
protSerialSector = * + 1  
        lda     #0                      ; serial sector (stamped in by build system)  
        sta     r1H  
        jsr     _GetBlock               ; read sector that contains code with serial  
        bnex    @ierror

Note that the track and sector numbers point to the location of this very code on disk. The geoWrite build system creates a disk image with the app on it and stamps track and sector numbers in.

The serial has to be put at the correct location within the block and encrypted with the same XOR $DE that is used for decrypting all of record 1:

@offset = (serial-CODE1) .mod 254 + 2  
        lda     serial                  ; get serial low  
        eor     #$DE                    ; "encrypt"  
        sta     diskBlkBuf+@offset  
        lda     serial+1                ; get serial high  
        eor     #$DE                    ; "encrypt"  
        sta     diskBlkBuf+@offset+1

The offset can be calculated by the assembler: It’s the offset of the serial in the current (record 1) code, modulus 254 (because blocks on disk are 254 bytes), plus 2 (number of header bytes at the start of each block).

        LoadW   r4, diskBlkBuf  
        jsr     _PutBlock               ; write back block  
        beqx    installOk

        cpx     #WR_PR_ON  
        beq     @wperr  
@ierror:  
        lda     #<txt_error_installing  
        ldy     #>txt_error_installing  
        bra     showErrorAndExit

@wperr: lda     #<txt_install_write_protected  
        ldy     #>txt_install_write_protected

showErrorAndExit:  
        jsr     showError  
        jsr     swap_userzp  
        jmp     EnterDeskTop

Finally, the block is written back. If there was an error, a dialog is shown, and the app exits.

If installation was successful, the following code runs:

installOk:  
        asl     serial                  ; cycle serial left to obfuscate  
        rol     serial+1                ; serial = serial[14..0,15]  
        lda     serial  
        adc     #0  
        sta     serial

        jsr     swap_userzp  
        jsr     GetDirHead              ; read BAM block  
        jsr     swap_userzp

        MoveW   serial, curDirHead+$BE  ; store serial after "GEOS format V1.x"

        jsr     swap_userzp  
        jsr     PutDirHead              ; write BAM block  
        jsr     swap_userzp

This writes a copy of the serial with the bits rotated into two unused bytes of the disk’s header block, track 18, sector 0.

The reason for this most probably had to do with ordering broken replacement disks: Once both the system and the backup disks didn’t boot any more, the user was supposed to send in both disks, and would get new disks in return, already installed with the same serial, so that existing apps would continue to function.

Side B of the system disk contains geoWrite, and side B of the backup disk contains geoMerge, both of which had to be installed – the manual explicitly instructs the user to open each app once. So after this, both boot disks contain the obfuscated but plaintext serial on track 18, sector 0, offset $BE of side B. This could then be used to create proper replacement disks. After all, sides A of both disks were broken.

Finally, it shows a success dialog and exits.

        lda     #<txt_installed  
        ldy     #>txt_installed         ; show success  
        jsr     showError  
        jsr     swap_userzp  
        jmp     EnterDeskTop            ; and exit

Disk Signature Check

We skipped over executeDiskBlock, which was called by the installer. First, it initializes the disk:

executeDiskBlock:  
        PushW   r1  
        jsr     swap_userzp  
        jsr     NewDisk  
        jsr     swap_userzp  
        PopW    r1  
        bnex    @rts                    ; I/O error -> fail

Then it reads the block whose track and sector was passed in by the caller. It’s the location of the protection check code that was stamped in by the build system.

        LoadW   r4, diskBlkBuf          ; read block  
        jsr     _GetBlock  
        bnex    @rts                    ; I/O error -> fail

The last byte of the block is a checksum which is the lower 8 bits of the sum of all payload bytes of the sector:

        lda     #0  
        ldy     #2  
@loop:  clc  
        adc     diskBlkBuf,y            ; checksum bytes $02-$FE  
        iny  
        cpy     #$FF  
        bne     @loop  
        cmp     diskBlkBuf+$FF          ; checksum at offset $FF  
        beq     @ok

        ldx     #$FF                    ; fail  
@rts:   rts

If the checksum matches, the block is run in place in the disk block buffer (diskBlkBuf at $8000):

@ok:    jsr     swap_userzp  
        jsr     diskBlkBuf+2            ; execute block  
        jsr     swap_userzp  
        rts

The protection check block is not part of the VLIR file and not formally referenced anywhere. The build system just writes it to a random free block when it generates the final disk image. In fact, it writes another 6 unused decoy copies of the block.

This is the layout of this block:

00:  00 ff                                             block link pointer

           4c 7a 80                                    jump at entry point

                    ad 0f 18  48 29 df 8d 0f 18 20 16  
10:  07 68 8d 0f 18 60 ba 86  49 a9 ee 8d 0c 1c a9 07  
20:  85 33 a9 f5 85 32 a5 22  8d f5 07 20 10 f5 a0 02  
30:  84 00 20 55 07 a2 10 d0  12 a0 00 20 55 07 a0 45  drive code  
40:  20 55 07 20 64 07 a0 0a  20 55 07 20 64 07 ca d0  
50:  e8 e8 86 00 60 50 fe b8  ad 01 1c 88 d0 f7 60 ad  
60:  01 1c b8 60 ac 00 1c 10  f6 50 f9 b8 ad 01 1c c9  
70:  55 f0 f1 c9 67 f0 ed 68  68 60

                                    ad e3 c1 85 03 ad  
80:  e2 c1 85 02 ad e1 c1 c9  4c f0 0c a0 00 b1 02 aa  
90:  c8 b1 02 85 03 86 02 a2  0a ac 89 84 b9 86 84 29  
a0:  bf c9 02 90 24 d0 57 a8  88 88 b1 02 c8 c9 20 d0  computer code  
b0:  f9 b1 02 c8 c9 5c d0 f1  b1 02 c8 c9 c2 d0 e9 b1  
c0:  02 c8 c9 20 d0 f9 88 d0  11 a0 ff c8 b1 02 c9 85  
d0:  d0 f9 c8 b1 02 c9 8b d0  f3 c8 a2 00 b1 02 9d f5  
e0:  80 c8 e8 e0 06 d0 f5 20  14 c2 20 5c c2 a9 05 85  
f0:  8b a2 07 86 8c 00 00 00  00 00 00 20 5f c2 60

                                                   d7  checksum

The second half of the block is GEOS code that executes on the computer side, and the first part is code that runs on the disk drive. The drive code does the actual disk authenticity check. The job of the computer part is to get the disk drive to run the drive code, and receive the result.

Running the Drive Code

GEOS comes with driver code for the 1541 and 1571 disk drives, which contains logic to upload code to the drive, execute it, and send commands, status messages and block data back and forth. The protection code reuses the driver to execute custom code and retrieve the result.

But the disk drivers don’t export this functionality as an API. Instead of adding this to the disk drivers as a private API, the authors of the protection chose to do some hacky stuff to get to these functions instead. This has the side effect of making it much harder to understand what is going on – which is a plus for protection code.

The computer part needs to call the private functions sendExecuteWithTrkSec and getDOSError in the driver. This is some code in the 1541 driver that calls both functions in sequence:

__NewDisk:  
        jsr     EnterTurbo  
        bnex    NewDsk2  
        jsr     ClearCache  
        jsr     InitForIO  
        LoadB   errCount, 0  
NewDsk0:  
        lda     #>Drv_NewDisk  
        sta     $8C  
        lda     #<Drv_NewDisk  
        sta     $8B  
        jsr     SendExecuteWithTrkSec   ; <------  
        jsr     GetDOSError             ; <------  
        beq     NewDsk1

The protection code scans the __NewDisk function for the STA $8B and steals the following two instructions.

It gets the pointer to the the function by looking at the GEOS KERNAL’s API jump table entry for the symbol NewDisk:

start:  lda     NewDisk+2               ; find the code that is pointed  
        sta     r0H                     ; to by the NewDisk API  
        lda     NewDisk+1               ; by reading the operand of the  
        sta     r0L                     ; direct/indirect JMP at the  
        lda     NewDisk                 ; entry point  
        cmp     #$4C                    ; direct or indirect JMP?  
        beq     @direct                 ; direct, then we found the code

If the KERNAL jumps directly to the driver code (opcode $4C), the two bytes after the API’s address point to the implementation. If it’s an indirect jump, it resolves the indirection:

        ldy     #0                      ; indirect jump, so  
        lda     (r0),y                  ; we need to read the vector  
        tax  
        iny  
        lda     (r0),y  
        sta     r0H                     ; and we have a pointer to the code  
        stx     r0L

The 1541 and 1571 drivers differ slightly, so it checks which type of driver is running:

@direct:  
        ldx     #STRUCT_MISMAT          ; default error code:  
        ldy     curDrive  
        lda     driveType-8,y           ; what kind of drive is this?  
        and     #$BF                    ; ignore the shadow bit (drive cache)  
        cmp     #$02                    ; 2: 1571  
        bcc     @is1541                 ; less, then 1541!  
        bne     @rts                    ; not 1571, then return with error (X != 0)

Let’s look at the 1541 code. (The 1571 is be similar.)

@is1541:  
        ldy     #$FF  
@loop5: iny  
@loop6: lda     (r0),y                  ; search for $85 $8B  
        cmp     #$85                    ; (STA $8B)  
        bne     @loop5                  ; in NewDisk code  
        iny  
        lda     (r0),y  
        cmp     #$8B  
        bne     @loop6

This looks for the STA $8B just before the two calls. It then appends the next two calls to the end of its own code:

        iny

@cont:  ldx     #$00  
@loop7: lda     (r0),y                  ; extract 6 bytes  
        sta     @code,x                 ; copy into this code  
        iny  
        inx  
        cpx     #6  
        bne     @loop7

Finally, it enables the disk driver, and calls the two functions it extracted the pointers of to have the driver execute code at checkProtection in its own RAM and retrieve the status.

        jsr     EnterTurbo  
        jsr     InitForIO

        lda     #<checkProtection       ; $0705 ptr in 1541 RAM  
        sta     $8B                     ; to execute  
        ldx     #>checkProtection       ; (this sector is at $0700!)  
        stx     $8C  
@code:  .byte   0,0,0                   ; jsr SendExecuteWithTrkSec  
        .byte   0,0,0                   ; jsr GetDOSError  
        jsr     DoneWithIO  
@rts:   rts

checkProtection is actually $0705, and points into the drive’s buffer at $0700-$07FF, which is where the block was read. So the drive code does not have to be uploaded from the computer – it was the last block read by the driver, and it guaranteed to be located at $0700.

Checking for the Gap Sequence

Tracks on a 1541-formatted disk contain the following sequence of structures for 17 to 21 sectors, depending on the track.

A SYNC mark is followed by a sector header, and after a gap, there is another SYNC mark, followed by the sector’s data and another gap. This is repeated for the next sector.

The GEOS copy protection relies on the fact that the data in the gap, which is irrelevant for normal operation, cannot be reliably written to by stock drives. GEOS boot and application disks contain the following sequence of bytes there:

55 55 55 67 55 55 55 67

The purpose of the drive code is to test that the gap after both the header and the sector data contain only the values 0x55 and 0x67 for 16 consecutive sectors.

Before we look at the main program, let’s look at its two helpers. This is skipBytes. It just reads a certain number of bytes from disk and ignores them.

skipBytes:  
        bvc     skipBytes               ; wait for byte  
        clv  
        lda     $1C01                   ; read it  
        dey  
        bne     skipBytes               ; y times  
        rts

And this is checkSignature, which reads all bytes up to the next sync mark and makes sure that they are all either 0x55 or 0x67:

checkSignature:  
        ldy     $1C00                   ; if we found the SYNC mark,  
        bpl     @foundSync              ; the check is ok and we're done

        bvc     checkSignature          ; wait until byte ready  
        clv

        lda     $1C01                   ; get byte  
        cmp     #$55  
        beq     checkSignature          ; has to be either signature byte $55  
        cmp     #$67  
        beq     checkSignature          ; or signature byte $67

        pla                             ; magic not found  
        pla                             ; -> return to main code  
        rts                             ; (error code remains at "2")

@foundSync:  
        lda     $1C01                   ; read value  
        clv  
        rts

If this enounters a gap byte other than 0x55 of 0x67, it pops the return address from the stack, which returns to the drive’s main code with an error.

The main code starts out with waiting until the read head passes the header of sector 0 of the current track and reading the header. It calls a function in the DOS ROM ($F510) for this:

        lda     #>(buffer-$8000+$0700)  
        sta     $33  
        lda     #<(buffer-$8000+$0700)  
        sta     $32                     ; set pointer to track/sector for ROM call  
        lda     $22                     ; current track number  
        sta     $07F5                   ; (sector is 0)  
        jsr     $F510                   ; ROM call: find and read block header

There are two more bytes that are part of the header that haven’t been read yet (the “OFF” bytes), which have to be skipped:

        ldy     #2  
        sty     $00                     ; set default error code 2: "READ ERROR"  
        jsr     skipBytes               ; skip 2 bytes

The next bytes to be read are now the sector header’s gap bytes.

The remainder of the code now iterates over 16 sectors, always skipping all header bytes and data bytes, and checking for 0x55 and 0x67 values in the gaps:

        ldx     #16  
        bne     @1                      ; check 16 headers and sectors

@loop:  ldy     #<$100                  ; skip a total of  
        jsr     skipBytes               ; 325 GCR bytes  
        ldy     #$45                    ; = 260 data bytes  
        jsr     skipBytes               ; = marker + full block + checksum + filler  
        jsr     checkSignature          ; check signature after data block  
        ldy     #10  
        jsr     skipBytes               ; skip full header  
@1:     jsr     checkSignature          ; check signature after header  
        dex  
        bne     @loop                   ; repeat  
        inx  
        stx     $00                     ; set error code 1: "OK"  
        rts

If checkSignature never failed, a status code indicating success will be set, which the computer part of the protection code will fetch.

Encrypted Code in Record 0

To make cracking the protection harder, there is one more component: Somewhere in the initialization code, record 1 checksums itself, and uses this checksum as a key to decrypt one function in record 0. So if someone was to crack the protection by changing any of the code in record 1, the checksum would be different, the one function in record 0 would be garbled, and the app would sooner or later crash.

First, the checksum code:

decryptR00:  
        LoadW   r0, MEM_OVERLAY         ; checksum code record #1  
        LoadW   r1, CODE1_END-CODE1  
        LoadB   r2L, 0  
        ldy     #0  
@loop1: lda     (r0),y  
        add     r2L  
        sta     r2L  
        IncW    r0  
        ldx     #r1  
        jsr     Ddec  
        bne     @loop1

It adds all bytes together and keeps the lowest 8 bits.

There are several bytes within the record 1 code that must not be part of the checksum though: The 2 bytes containing the serial number will change once the app is installed, and the value will be different on every user’s copy. So their values will be subtracted from the checksum again:

        lda     r2L                     ; remove variable bytes from checksum  
        sub     serial  
        sub     serial+1

Furthermore, the stamped-in track/sector pointers of the block with the signature check code and the block with the serial have to be excluded. This is because of the necessary order in the build process, which looks something like this:

  1. assemble the source of each record as well as the drive code block
  2. encrypt record 1 with a constant of $DE
  3. encrypt one function in record 0 with the checksum of the record 1 plaintext
  4. write the whole geoWrite VLIR file to a disk image
  5. write the drive code block to a free block on the disk image
  6. stamp the track and sector of the drive code block into record 1 on the disk image
  7. stamp the track and sector of the block that contains the serial into record 1 on the disk image

Both track/sector pointers aren’t known until step 6 and 7, but they are part of the checksum in step 3. Therefore, they are also excluded:

        sub     protExecTrack  
        sub     protExecSector  
        sub     protSerialTrack  
        sub     protSerialSector

Now it can decrypt the one function in record 0:

        LoadW   r0, r0_encrypted_start ; decrypt some code in record 0  
        LoadW   r1, r0_encrypted_start-r0_encrypted_end  
        ldy     #0  
@loop2: lda     (r0),y  
        eor     r2L  
        sta     (r0),y  
        IncW    r0  
        IncW    r1  
        bne     @loop2  
        rts

Discussion

While the geoWrite copy protection isn’t as complicated or quite as mean as the one on the GEOS system disks, it is nevertheless effective, and requires quite some effort to be cracked.

Without disassembling through the geoWrite binary, a cracker could search the whole disk for code that looks like it’s checking for the protection. Any code running on the disk and reading bytes from the head manually is a candidate. This is easy to find by looking for LDA $1C01, which would reveal the block with the gap signature check. But it’s checksummed, and the cracker wouldn’t know the algorithm, the range or the location of the checksum unless they had disassembled record 1. Besides, they might change the wrong block by mistake because of the decoy copies of this block on the disk.

So any cracking attempt would require disassembling through the geoWrite code. It is quite straightforward to find the code to load and decrypt record 1, and record 1 can either be decrypted with a small script using the key found in the code, or by dumping the memory contents after decryption.

The first few bytes of record 1 are a simple but clever obfuscation with the chance that the cracker would miss the serial check and installation code. If they do find it, they now know the track and sector of the serial on disk, and the encryption key, so they could change the serial of an installed copy, or deinstall geoWrite on the original disk.

The holy grail would be a cracked version of geoWrite that didn’t care about the serial, so a cracker could just remove the call to checkSerialOrInstall. But this would alter the checksum of record 1, and break the decryption of the one function in record 0, so the app would sooner or later crash. So removing the call to checkSerialOrInstall would also require patching the decryption to take a fixed key instead.

References

3 thoughts on “Inside geoWrite – 5: Copy Protection”

  1. Thanks for posting this!

    Were there any disk copy software back in the days for the 1571 to make copies of geos including the copy protection?

    Afaik the 1571 can write a complete track and thus generate whichever pattern you’d want. Or would there be a problen where a full written track ends and meets the already written start of the full track? That would seem like a problem with mass duplicated disks too though?

    • About the end meeting the already written start: The signature check code looks at 16 sectors starting from sector 0, but tracks have at least 17 sectors, so the signature between the last sector and sector 0 does not matter. If duplication starts at sector 0, this should be fine then.

Leave a Comment

This site uses Akismet to reduce spam. Learn how your comment data is processed.