Photo by Ilya Pavlov / Unsplash

Writeup: Old Calculator - upCTF (Reverse)

Feri Harjulianto

Challenge

Found my old TI in a box of high school stuff. Weird, there's a program on here I don't remember installing. Wrap the code in upCTF{...}

We're given a single file: PROG.8xp - a TI-83/84 calculator program.

Flag

upCTF{1F41L3DC4LCF0RTH1S}

Analysis

File Format

The .8xp format is the standard TI-83+ program file. The header identifies it as **TI83F*, generated by SPASM (a popular TI assembler). The type byte 0x06 and the BB 6D (AsmPrgm) token confirm this is a Z80 assembly program, not TI-BASIC.

Memory Layout

By cross-referencing string addresses in LD HL instructions with their file offsets, the base address is determined:

ComponentRAM AddressFile Offset
AsmPrgm token (BB 6D)$9D930x4A
First instruction (JP $9E57)$9D950x4C
Encoded data (18 bytes)$9D980x4F
Encrypted function (57 bytes)$9DAA0x61
"= CODE CHECKER ="$9DE50x9C
"CORRECT! :)"$9E130xCA
"WRONG. Try again."$9E1F0xD6

Program Structure

The program has four major components:

1. Main UI ($9E57) - Clears the screen, displays "= CODE CHECKER =", "Enter code (CLEAR=reset):", and "> ", then enters a key-reading loop. Accepts digits (keycodes 0x8E-0x97) and letters (0x9A-0xB3), storing up to 18 raw keycodes in a buffer at $86EC. Pressing CLEAR resets input; pressing ENTER triggers verification.

2. Custom Interrupt Handler ($9E31) - The program switches to IM 2 and installs a custom ISR. On each hardware interrupt tick, if input has been entered, the ISR advances an 8-bit LFSR:

SRL A          ; shift right, carry = bit 0
JR NC, skip
XOR $B8        ; feedback polynomial
skip:
DJNZ loop      ; repeat (input_count) times

The LFSR is seeded with $A5 and stored at $8749. The number of shifts is timing-dependent (based on interrupt ticks between keypresses).

3. Self-Decrypting Verification ($9F18) - When ENTER is pressed with exactly 18 characters:

  • The LFSR state is XORed with $17 to produce a decryption key
  • 57 bytes of encrypted data at $9DAA are XORed with this key
  • The result is written to $8710 as executable Z80 code
  • The decrypted function is called, then zeroed out (anti-forensics)

4. Decrypted Comparison Function ($8710) - The hidden function performs the actual code check using its own internal LFSR.

Breaking the Encryption

Since the decryption key is timing-dependent, we brute-force all 256 possible XOR keys. Key 0xA7 produces clean, valid Z80 code:

verify:
    PUSH DE              ; save user input pointer ($86EC)
    LD DE, $86FE         ; temp buffer
    ; Decrypt expected keycodes using LFSR
.loop1:
    LD A, C              ; C starts at $A5
    SRL A                ; LFSR step
    JR NC, .no_xor
    XOR $B8              ; polynomial feedback
.no_xor:
    LD C, A              ; save new state
    LD A, (HL)           ; load encoded expected byte
    XOR C                ; decrypt with LFSR state
    LD (DE), A           ; store decrypted keycode
    INC HL / INC DE
    DJNZ .loop1

    ; Compare user input against decrypted expected
    LD HL, $86FE
    POP DE               ; DE = user input at $86EC
.loop2:
    LD A, (DE)
    CP (HL)              ; compare byte-by-byte
    JR NZ, .wrong
    INC HL / INC DE
    DJNZ .loop2

    AND A                ; clear carry = CORRECT
    RET

.wrong:
    SCF                  ; set carry = WRONG
    RET

Computing the Code

The verification function decrypts the 18 stored bytes at $9D98 using a self-contained LFSR (seed $A5, polynomial $B8). This is independent of the timing-dependent ISR state - the function carries its own LFSR.

Encoded data: 65 EA 10 CE 3D DD BB 8F 23 45 EC A7 92 A5 AA 1A 6A 66

LFSR seed: $A5
Step:  SRL, if carry XOR $B8, then XOR with data byte

Step  1: LFSR=$EA  0x65^0xEA = 0x8F -> '1'
Step  2: LFSR=$75  0xEA^0x75 = 0x9F -> 'F'
Step  3: LFSR=$82  0x10^0x82 = 0x92 -> '4'
Step  4: LFSR=$41  0xCE^0x41 = 0x8F -> '1'
Step  5: LFSR=$98  0x3D^0x98 = 0xA5 -> 'L'
Step  6: LFSR=$4C  0xDD^0x4C = 0x91 -> '3'
Step  7: LFSR=$26  0xBB^0x26 = 0x9D -> 'D'
Step  8: LFSR=$13  0x8F^0x13 = 0x9C -> 'C'
Step  9: LFSR=$B1  0x23^0xB1 = 0x92 -> '4'
Step 10: LFSR=$E0  0x45^0xE0 = 0xA5 -> 'L'
Step 11: LFSR=$70  0xEC^0x70 = 0x9C -> 'C'
Step 12: LFSR=$38  0xA7^0x38 = 0x9F -> 'F'
Step 13: LFSR=$1C  0x92^0x1C = 0x8E -> '0'
Step 14: LFSR=$0E  0xA5^0x0E = 0xAB -> 'R'
Step 15: LFSR=$07  0xAA^0x07 = 0xAD -> 'T'
Step 16: LFSR=$BB  0x1A^0xBB = 0xA1 -> 'H'
Step 17: LFSR=$E5  0x6A^0xE5 = 0x8F -> '1'
Step 18: LFSR=$CA  0x66^0xCA = 0xAC -> 'S'

TI-83+ _GetKey keycodes: 0x8E-0x97 = digits 0-9, 0x9A-0xB3 = letters A-Z.

Result

The code is 1F41L3DC4LCF0RTH1S, which in leet speak reads:

I FAILED CALC FOR THIS

A fitting message for a mysterious program found on an old high school calculator.

CTFReverseWriteup