(How’s that for a punny subtitle?)
It’s been a while. I haven’t lost interest, I’ve just been busy with work and other things. Life has a funny way of sneaking up on you. š
The wait is worth it, though. Buckle up; this one’s a doozy.
Apple hasn’t documented the Pippin’s authentication process beyond what developers needed to know. There exists a technote that was distributed via the SDK(s) that gives an overview of what developers were expected to do to get their discs signed before final mastering and duplication. The Pippin’s authenticated boot process hinges upon the presence of a specially-crafted, RSA-signed file unique to each disc called the “PippinAuthenticationFile.” Since the Pippin platform was abandoned and subsequently cancelled in 1998, Apple no longer signs Pippin discs nor have they made available the means for third parties to do so. To my knowledge, most of the specifics of how the PippinAuthenticationFile plays a role in the Pippin’s boot process have never been documented outside of Apple.
That changes today.
This post is pretty dense, so I highly recommend (re-)reading parts 1 through 4 for some background before getting too deep. Otherwise, here’s a quick recap: during every boot, a retail Pippin console locates a potential boot volume on CD, loads an ‘rvpr’ 0 resource from ROM, then calls the code therein in order to verify that the target volume passes an authentication check allowing it to boot the system. (An aside: Previously, I asserted that while I found identical copies of ‘rvpr’ 0 in the 1.2 and 1.3 ROMs, I couldn’t find an entry for it in the resource map, therefore it must either be dead code or called some other way. This conclusion turned out to be incorrectāthe resource map is not contiguous in ROMs 1.2 and 1.3, which made manually searching it more difficult, but it does indeed contain an entry for ‘rvpr’ 0. The authentication process is therefore identical between ROM 1.0 and 1.2.) When I last looked at ‘rvpr’ 0, I was stymied by a routine called upon entry which, absent of any symbols to help point me toward its purpose, I conjectured used a complex block of data at the end of the resource to “decrypt” the code therein. After taking a closer look a few days ago, I was delighted to find that its purpose is much simplerāit exists to patch the absolute memory locations in the code so they are relative to the buffer where ‘rvpr’ 0 is loaded. Without these patches, the code would crash the Pippin on boot practically every time!
The way this routine accomplishes this is kind of elegant. We initialize a cursor pointer to the beginning of our buffer where ‘rvpr’ 0 is loaded. The offset table starting at offset $8A47 from the start of ‘rvpr’ 0 begins with a 32-bit longword defining the size of the table. Then, the table itself is compressed: a byte with bit 7 set means it’s a relative sign-extended 7-bit offset from our cursor position, a byte with bit 6 set means it along with the next byte form a sign-extended 14-bit offset from our cursor position, but if both bit 6 and 7 are clear, then combine the next three bytes to form a 30-bit absolute cursor position. Multiply these offsets by two before applying them (because 68K opcodes are always at least two bytes), add the address of our ‘rvpr’ 0 buffer to the 32-bit longword pointed to by our cursor, then repeat the process until we’ve exhausted the offset table. Easy peasy.
@loopBody
@checkBit7
5E 1218 Move.B (A0)+, D1 ; grab the next byte into D1, we'll call it the command byte
60 1001 Move.B D1, D0
62 0240 0080 AndI #$80, D0 ; is bit 7 set?
66 670C BEQ.B @checkBit6 ; then handle the bit 6 case
; command byte bit 7 is set, so
; D2 += signExtend(D1 * 2) as a byte (* 2 because alignment)
68 D201 Add.B D1, D1
6A 1001 Move.B D1, D0
6C 4880 Ext D0
6E 48C0 Ext.L D0
70 D480 Add.L D0, D2
72 6028 Bra.B @gotOffset
@checkBit6
; else, command byte bit 7 not set...
74 1E81 Move.B D1, (A7) ; put D1 into the highest byte of temp
76 1F58 0001 Move.B (A0)+, $1(A7) ; grab the next byte into the 2nd byte of temp
7A 1001 Move.B D1, D0
7C 0240 0040 AndI #$40, D0 ; is bit 6 of D1 set?
80 670C BEQ.B @get32BitOffset ; yes? then
; command byte bit 6 is set, so
; our address offset is only 14 bits
82 3017 Move (A7), D0 ; grab the new temp into D0
84 E548 LsL #2, D0 ; D0 <<= 2
86 E240 AsR #1, D0 ; D0 /= 2
88 48C0 Ext.L D0 ; sign extend it
8A D480 Add.L D0, D2 ; D0 is the found offset * 2 (because alignment), add to our current offset
8C 600E Bra.B @gotOffset ; apply it
@get32BitOffset
; bit 6 not set...
8E 1F58 0002 Move.B (A0)+, $2(A7) ; grab the next byte into the 3rd byte of temp
92 1F58 0003 Move.B (A0)+, $3(A7) ; grab the next byte into the 4th byte of temp
96 2417 Move.L (A7), D2 ; D2 is a brand new offset!
98 E58A LsL.L #2, D2 ; D2 <<= 2
9A E282 AsR.L #1, D2 ; D2 /= 2
@gotOffset
; D2 == the offset we want to apply to argument 2
; D6 == the offset we want to apply to the longword found there (typically @start)
9C DDB1 2800 Add.L D6, $0(A1,D2.L) ; add D6 to the longword at (@start + D2)
A0 5385 SubQ.L #1, D5
@forLoopCheck
A2 4A85 Tst.L D5 ; are we out of longs to patch?
A4 6EB8 BGT.B @loopBody
Now that we've "unpacked" the code of 'rvpr' 0, let's dig into it. š
main
starts off initializing a number of globals, first by calling InitRSAAlgorithmChooser
and then a handful of other subroutines. It then initializes some local variables on the stack: previous A4, values related to the PippinAuthenticationFile, and a ParamBlockRec for calls to _Read
. In addition, among those locals is a 16-byte temporary buffer for digests created during the main loop.
Recall from part 2 that the Pippin ROM passes as input to 'rvpr' 0 the following arguments: two pointers to some as-of-yet-unknown data in ROM shortly preceding the callsite, the ID of the boot volume candidate, and the refNum of the candidate's disk driver. After we've initialized our variables, we hit the ground running by calling GetVolAuthFileInfo
to fetch the offset to and size of the PippinAuthenticationFile. Note that if at any point during 'rvpr' 0 one of its internal subroutines fails, the entire process is reported as having failed the authentication check.
24C 41EE FFAA Lea.L -$56(A6), A0 ; A0 -> temp var for created digests
250 2E08 Move.L A0, D7 ; D7 == ptr to temp digest
252 486E FFCA Pea.L -$36(A6) ; pass size out address
256 486E FFBE Pea.L -$42(A6) ; pass offset out address
25A 3F05 Move D5, -(A7) ; $10(A6) is dqDrive passed in from ROM
25C 3F2E 0012 Move $12(A6), -(A7) ; $12(A6) is dqRefNum passed in from ROM
260 4EB9 0000 03E6 Jsr GetVolAuthFileInfo
266 3600 Move D0, D3
268 4A43 Tst D3
26A 4FEF 000C Lea.L $C(A7), A7
26E 6600 0142 BNE @mainCleanup ; if GetVolAuthFileInfo returns nonzero, fail
272 202E FFBE Move.L -$42(A6), D0 ; D0 = offset from start of HFS volume to PippinAuthenticationFile in allocation blocks
276 7209 MoveQ.L #9, D1
278 E3A8 LsL.L D1, D0 ; D0 = offset from start of HFS volume to PippinAuthenticationFile in bytes
27A 2D40 FFC6 Move.L D0, -$3A(A6) ; save the offset into -$3A(A6)
27E 202E FFCA Move.L -$36(A6), D0 ; D0 = size of the PippinAuthenticationFile in bytes
282 A11E _NewPtr
284 2648 MoveA.L A0, A3
286 200B Move.L A3, D0
288 4A80 Tst.L D0
28A 660E BNE.B @gotAuthBuffer ; if _NewPtr returns null, clean up the stack, and fail
28C 554F SubQ #2, A7
28E 3EB8 0220 Move (MemErr), (A7)
292 301F Move (A7)+, D0
294 3600 Move D0, D3
296 6000 011A Bra @mainCleanup
@gotAuthBuffer
29A 3D6E 0012 FFE6 Move $12(A6), ioRefNum(A6)
2A0 3D45 FFE4 Move D5, ioVRefNum(A6) ; dqDrive
2A4 2D4B FFEE Move.L A3, ioBuffer(A6)
2A8 2D6E FFCA FFF2 Move.L -$36(A6), ioReqCount(A6) ; size of the PippinAuthenticationFile
2AE 3D7C 0001 FFFA Move #fsFromStar, ioPosMode(A6)
2B4 202E FFBE Move.L -$42(A6), D0 ; offset from start of HFS volume to PippinAuthenticationFile in device blocks
2B8 7209 MoveQ.L #9, D1
2BA E3A8 LsL.L D1, D0 ; get the offset in bytes by multiplying by device block size (512 bytes)
2BC 2D40 FFFC Move.L D0, ioPosOffset(A6)
2C0 41EE FFCE Lea.L -$32(A6), A0
2C4 A002 _Read
2C6 3600 Move D0, D3
2C8 4A43 Tst D3
2CA 6600 00E6 BNE @mainCleanup ; if _Read returns something other than noErr, fail
Following this call, we allocate enough space for the file by calling _NewPtr
. We then call _Read
with our local ParamBlockRec filled with the disk driver refNum, the volume refNum, the pointer to the buffer we just allocated, our buffer's size, and the byte offset to the authentication file on our candidate. Armed with the contents of the PippinAuthenticationFile, we then pass them to VerifyDigestInfo
to verify the signature contained therein. If that succeeds, we're clear to start verifying the candidate's contents against this file, so we allocate temp space large enough to load a single "chunk" of data from the candidate to be hashed and verified.
Every Pippin except the KMP 2000 shipped with a built-in 4x speed CD-ROM drive. A 1x CD-ROM drive can read data at a rate of 150KB/sec, which is the speed necessary for smooth playback of audio CDs. A 2x drive doubles that rate to 300KB/sec, a 4x drive quadruples it to 600KB/sec, and so on. At 600KB/sec, it would take a Pippin almost a full minute to read just over 35MB, and nearly 20 minutes to read the entire contents of a 700MB CD-ROM. Even the KMP 2000 with its 8x drive would take almost 10 minutes to do the same. Hashing the entire contents of a CD during every boot would be unacceptable at this speed, and since the Pippin only takes a couple seconds to verify a disc at startup, it's clearly not verifying the whole thing. So what does the Pippin do?
@topOfMainForLoop
320 4A86 Tst.L D6
322 6604 BNE.B @pickRandomChunk
324 7800 MoveQ.L #0, D4
326 6016 Bra.B @readChunk
@pickRandomChunk
328 202B 004C Move.L $4C(A3), D0 ; longword after the 128K size field at $48, appears to be number of entries in table
32C 5380 SubQ.L #1, D0
32E 2F00 Move.L D0, -(A7) ; upper bound == total number of chunks
330 4878 0001 Pea.L ($1) ; lower bound == 1
334 4EB9 0000 0814 Jsr RangedRand ; patched
33A 2800 Move.L D0, D4 ; D4 == pseudorandom integer between [1, <total number of chunks>]?
33C 504F AddQ #8, A7 ; clean up stack
@readChunk
33E 2004 Move.L D4, D0 ; D0 == pseudorandom integer in the lowword, probably
340 2205 Move.L D5, D1 ; D1 == 128K? 0 x 0002 0000
342 4EB9 0000 0116 Jsr _D0timesD1 ; patched, does some weird multiplication, returns in D0
348 2D40 FFFC Move.L D0, ioPosOffset(A6) ; D0 == the pseudorandom integer * 128K? D0 == offset to random 128K chunk in disc?
34C 41EE FFCE Lea.L -$32(A6), A0
350 A002 _Read
352 3600 Move D0, D3
354 4A43 Tst D3
356 665A BNE.B @mainCleanup
Put simply, the Pippin randomly spot-checks the candidate volume's contents every boot. The PippinAuthenticationFile isn't just a key, it isn't just a single hashāit is in fact a collection of hashes corresponding to as many 128K chunks of data that make up the boot volume. main
enters a loop that iterates six times: the first check, it loads the first 128K of the volume containing important metadata about the HFS filesystem into our temporary buffer, and then verifies that data against its corresponding digested hash previously loaded from the PippinAuthenticationFile. The remaining five checks, it does the same, but on randomly selected other 128K chunks of the volume. This way, the Pippin only has to load and verify 768Kāa process that takes less than a couple seconds on its 4x CD-ROM drive. But because this loop selects five of those six input chunks at random each run-through, the PippinAuthenticationFile still needs digests of the entire volume. For it's not known ahead of time which five chunks will be verified and furthermore, they rarely will be the same five chunks.
Examining several PippinAuthenticationFile examples with this code in mind quickly reveals how this file is structured. Both the chunk size and the total number of chunks in the volume are stored in a common header. This loop uses that information to determine the upper bound of which chunks to select at random and how large. Following these two fields is a table of digested 128-bit hashes corresponding, in sequential order, to the chunks in the volume. Finally, there is a signature near the end of the file, which gets verified in the call to VerifyDigestInfo
before entering the loop. The process by which a PippinAuthenticationFile is created, therefore, is essentially as follows:
- Get the size of the target volume.
- Integer divide this size into 128K chunks. Call the total number of chunks N.
- Allocate 80 bytes for a file header.
- Multiply N chunks by the 16-byte size of each digest (N * 16). Call this table size T. Allocate T bytes for digests of each 128K chunk.
- Allocate 16 bytes for the signature size S.
- Pad the signature size until it is a multiple of 16 bytes (((S + 16) % 16) *16). Call this padded size P. Allocate P bytes for the signature itself.
- Pad additional bytes until the total file size is the next multiple of the device block size (512 bytes). The total file size therefore should be ((80 + T + 16 + P + 512) % 512) * 512.
- Preallocate a blank version of this file on the target volume.
- Starting at offset 80, compute and store a 16-byte (128-bit) digest for each of the N sequential 128K chunks. Note that to compute the digests for the entire finalized volume correctly, this file must already exist in the filesystem. It is therefore necessary to compute the size of this file in advance, preallocate a "dummy" version of it on the target volume, then compute the digests and overwrite the file in-place.
- At offset 80 + T + 15, store the signature size S as a byte (only one byte at the end of this space is actually used, the rest are zeroes).
- At offset 80 + T + 16 + 3, store the signature itself. The signature always seems to be 45 bytes long, placed such that it ends on a 16-byte boundary, explaining the extra 3-byte offset.
- Fill in the file header at the beginning of the file:
- offset 0 (4 bytes): offset to signature size byte (80 + T + 15)
- offset 4 (4 bytes): longword equal to zero (version?)
- offset 8 (64 bytes): copyright notice (60 bytes, zero-padded right)
- offset 72 (4 bytes): chunk size longword equal to 128K, or $20000
- offset 76 (4 bytes): chunk count longword equal to N
Apple probably provided a tool that automated this process for stamping houses. Said tool presumably would have named the aforementioned file "PippinAuthenticationFile" with type/creator 'PpnV'/'PpnA' and saved it to the filesystem root. I imagine that this same tool likely would have filled the file's contents in-place with the signed version received from Apple. However, I have never seen such a tool in the wild so this is pure speculation on my part.
Incidentally, the name, placement within the folder hierarchy, and type/creator codes of the authentication file itself are inconsequential. The Pippin makes no HFS calls to locate the PippinAuthenticationFileāit could technically be buried within a nest of folders or named "FoobarAuthenticationFile." The verification code does not care. Instead, it fetches the Master Directory Blockā512 bytes located at byte offset 1024 from the start of the boot volume. The "logical" MDB is a data structure 161 bytes in size and found immediately at the start of this "physical" MDB. However, that leaves 351 bytes unaccounted for. For Pippin CD-ROMs, Apple chose to set aside two 32-bit longwords at the end of the physical MDB for the purpose of locating the PippinAuthenticationFile at the block level. The first of these longwords defines the offset, in 512-byte blocks from the start of the volume, to the contents of the authentication file. The second of these longwords define the authentication file's size in bytes.
(As an aside, this mechanism is one reason why deleting the PippinAuthenticationFile and naively replacing it with a new version at the filesystem level is not likely to work. The new file would likely reside starting at a different allocation block in the volume; the offset in the MDB would still point to where the deleted file was/is, and HFS wouldn't know to patch it upāwhy should it?)
One important component of creating the authentication file, and verifying against it, is the concept of chunk "cleansing." Once the loop selects and loads a chunk, it passes it to CleanseInputChunk
to optionally "cleanse" it. What does that mean in this context?
358 2F2E FFCA Move.L -$36(A6), -(A7) ; size of the PippinAuthenticationFile in bytes
35C 2F2E FFC6 Move.L -$3A(A6), -(A7) ; offset from start of HFS volume to PippinAuthenticationFile in bytes
360 2F05 Move.L D5, -(A7) ; 128K
362 2F2E FFFC Move.L ioPosOffset(A6), -(A7) ; the computed offset
366 2F0A Move.L A2, -(A7) ; working chunk buffer
368 4EB9 0000 049A Jsr CleanseInputChunk ; patched, remove the auth file from this chunk if we happened to land on it
The digested hashes contained within the authentication file do not include hashing the authentication file itself, for obvious reasons. Similarly, certain fields in the MDB should not be hashed because they change upon writing the final signed authentication file to the volume. "Cleansing" solves this problem by zeroing out these areas before hashing and digesting them. While creating an authentication file, the blocks of the volume containing the file itself should be zeroed out when hashing, and likewise most of the MDB. Upon loading a chunk, the verification loop checks its offset to see whether the chunk overlaps the MDB or the authentication file. If so, it zeroes out that data so that the digested hash matches the corresponding one stored in the authentication file.
Finally, the loop passes the chunk to CreateDigest
, and compares its result byte-for-byte with the digest in the authentication file by calling CompareDigests
. If all six digested chunks match what's found in the authentication file, we pass the check (and can boot from this volume! Yay!). Otherwise, we return -1 to indicate failure.
36E 2F07 Move.L D7, -(A7) ; D7 -> out buffer for temp digest
370 2F05 Move.L D5, -(A7) ; chunk size (128K)
372 2F0A Move.L A2, -(A7) ; cleansed working chunk buffer
374 4EB9 0000 06EE Jsr CreateDigest ; patched
37A 3600 Move D0, D3
37C 4A43 Tst D3
37E 4FEF 0020 Lea.L $20(A7), A7 ; clean up stack
382 662E BNE.B mainCleanup
384 2F07 Move.L D7, -(A7) ; D7 -> the digest we just created
386 2004 Move.L D4, D0 ; D4 == which chunk this is
388 E988 LsL.L #4, D0 ; D4 == chunk * 16 bytes (128 bit hash per chunk)
38A 206E FFC2 MoveA.L -$3E(A6), A0 ; A0 -> chunk hashes
38E D1C0 AddA.L D0, A0 ; A0 -> hash of this chunk
390 4850 Pea.L (A0)
392 4EB9 0000 07D4 Jsr CompareDigests ; patched, returns zero in D0 if digests match
398 7200 MoveQ.L #0, D1
39A 1200 Move.B D0, D1
39C 3601 Move D1, D3
39E 4A43 Tst D3
3A0 504F AddQ #8, A7 ; clean up stack
3A2 6704 BEQ.B @nextMainForLoopIteration
3A4 76FF MoveQ.L #-1, D3 ; D3 == -1, fail
3A6 600A Bra.B mainCleanup
@nextMainForLoopIteration
3A8 5286 AddQ.L #1, D6
@endOfMainForLoop
3AA 7006 MoveQ.L #6, D0
3AC BC80 Cmp.L D0, D6
3AE 6D00 FF70 BLT @topOfMainForLoop
So what's left?
Of the named functions I found in part 2, only four of them I have yet to step through and understand:
main |
done |
GetVolAuthFileInfo |
done |
CleanseInputChunk |
done |
VerifyDigestInfo |
in progress |
VerifySignature |
to do |
CreateDigest |
in progress |
CompareDigests |
done |
RangedRand |
done |
CleanseVCB |
done |
GetVAFileInfoGivenMDB |
done |
InitRSAAlgorithmChooser |
in progress |
Astute readers may notice that I have provided sparse details about the functions related to dealing with digests and signatures so far. Those are next on my list but will also be the hardest to grok because I'll be more or less "flying solo" without any symbols whatsoever to guide me. Fortunately I have a passing familiarity with the RSA algorithm so I have a vague idea of what logic to look for. I have already found functions for 32-bit multiply and 32-bit modulo, both of which are essential for RSA.
In part 4 I explored hacking the Apple Partition Map to load custom disk drivers before the authentication check takes effect. I was not successful with that experiment and decided not to pursue it further, but my discoveries here reveal a possible alternate avenue to explore: additional partitions. The authentication check is performed on the boot volume, and only the boot volume (emphasis on each word). The partition map is not included in the check, nor are any other partitions. It should be totally possible to take an existing signed Pippin disc with unpartitioned free space available (for example, the "Tuscon" disc) and graft an additional HFS partition containing whatever apps or documents one might want. The OS should mount the other partition as it would normally, without performing any checks beyond what would be done on a real Mac.
I leave that as an exercise for the reader. I'm going after the big fish: authoring an entire homebrew disc from scratch.
Wonderful.
Time for the careful hunt for bugs.
I suppose the most important thing to check out is what the minimum signature size is. If it will accept any size, pick 0. Otherwise, pick the smallest valid size to make it easier to brute-force. A 45-byte signature (360 bit RSA?) seems kind of weak anyways.
I suggest getting a SCSI2SD or similar so you don’t have to actually burn 250 cds when you are testing stuff.
It’s not obvious from skimming through the signature verification code what checks it makes (if any) on the incoming size.
VerifySignature
passes the signature/size through a couple levels of calls before finally passing it along to a function pointer, the location and purpose of which I have yet to discover.Assuming a minimally-sized signature works, that still leaves the problem of verifying the hashes. I did notice that the field at offset $4C in the authentication file is blindly used in the main loop with no sanity checking on it at all. If that value is set to zero, it gets fed to
RangedRand
which will use it as a modulus, causing a zero divide exception. But if it’s set to one,RangedRand
will do a modulo 1 (which always returns zero) and add that to the lower bound of 1, guaranteeing that the “random” chunk is always the second chunk. The main loop therefore will only check the first 256K of the volume: the first chunk once, and then the second chunk five times in a row. Might be worth looking into for any intrepid HFS hackers.And then you set the chunk size to 1 (or maybe 0), so it only verifies the first 2 bytes of the volume š
Though once you get the signature cracked, all you have to do is modify Elliot’s machfs tools to build an image with the auth file built in.
The chunk size needs to be a multiple of 512 bytes, as it is passed directly to the disk driver. However, you’ve got the right ideaāthe first 1024 bytes of an HFS volume tend to be pretty static between different volumes…
Well, wow. Great read! Can you tell us which byte ranges of the Master Directory Block are ācleansedā?
Sure. Cleansing the MDB goes like this:
Hi,
I have try to make a CD with two partition, one with a copy from the Tuscon CD and the second with data (and formated in Mac OS Standard).
Actually, it’s not a real success. On my modern Mac and on a Mac OS 9 machine, i see the two partition, but not on the Pippin. But i can boot the CD : it works for that. And i have no idea how to mount the second partition from the CD.
Really? That’s disappointing. The Tuscon CD has a copy of SCSIProbe in the Tools folderācan SCSIProbe be used to manually mount the second partition?