sunshinectf 2022 writeups part 2


This past weekend, I participated in SunshineCTF along with ProjectSEKAI. We were able to get full solves on all of the challenges across the board and place 1st!

I wrote about some of these challenges in a previous post. Below are some of the other challenges that I helped to solve.

PEG_DEBUG (Pegasus, 200pts, 21 solves)

This challenge is the 2nd in a series of related problems involving a custom CPU architecture and programs written for it. The first one was just an overview of the architecture and how to run sample programs.

You manage to connect to the JTAG interface of the console! However, the debug interface provided over JTAG is partially locked down and only provides non-intrusive debugging capabilities. Maybe that will be enough to bypass the password protection on the device?

Note: There are intentionally no files delivered as part of this challenge. You need to use the interactive debugger to solve it.

We were given a VM we could ssh into that provides pretty nice debugging interface for these Pegasus programs, which we could attach to a separate live instance of the challenge program.

Connect to the PEGASUS session on port 22700 with session ID '059dde68a35a605d'.

EAR debugger
(dbg) help
Available topics (type help <topic> to learn more):
breakpoint      -- Setting and modifying breakpoints
running         -- Controlling how a program runs and getting info
quit            -- Exit the debugger and stop execution

We can set breakpoints at various addresses, step into individual instructions, hexdump data at arbitrary addresses, and more.

Onto the program, we have a fairly simple routine:

Thread state:
   (ZERO)R0: 0000      (S1)R8: 0000
     (A0)R1: 0000      (S2)R9: 0000
  (RV/A1)R2: 0000  (FP/S3)R10: FB00
 (RVX/A2)R3: 0000     (SP)R11: FB00
     (A3)R4: 0000     (RA)R12: FFFF
     (A4)R5: 0000     (RD)R13: FFFF
     (A5)R6: EA2A     (PC)R14: 0118
     (S0)R7: 0000    (DPC)R15: 0000
FLAGS: Zspcvm
(dbg) dis 20
0118.0000: MOV     S0, ZERO 
011A.0000: RDB     S1, (15) <-+
011C.0000: BRR.GE  0xC -----+ |
011F.0000: RDB     A0, (0)  | |
0121.0000: BRR.GE  0xC  ----|-|-+
0124.0000: SUB     A0, S1   | | |
0126.0000: ORR     S0, A0   | | |
0128.0000: BRR     0xFFEF --|-+ |
012B.0000: MOV     S0, S0 <-+   |
012D.0000: BRR.EQ  0x8 <--------+
0130.0000: ADD     A0, PC, 0xC    |
0135.0000: BRR     0x5 -----------|-+
0138.0000: ADD     A0, PC, 0xB    | |
013D.0000: FCR     0xFFC0         | |
0140.0000: HLT <------------------+-+

I added some ascii arrows to try and help convey the program flow a bit better. Before describing the routine, I'll give a few brief notes about this architecture:

The above routine reads in a byte one at a time from two places, from port 15 into S1 and from port 0 into A0. I found out that port 0 is stdin, while port 15 is likely sourced from some file into the program. When RDB fails to read data, it will set the carry flag C to 1, and trigger any GE-conditional instructions.

We can set a breakpoint at 0x121 in the debugger and sent a bunch of garbage data to the program instance and simply read off the values that pop up in S1.

Reading all of these values and decoding to ASCII results in the flag: sun{d1d_y0u_u53_br34kp01nt5?}

PEG_CHEAT (Pegasus, 300pts, 12 solves)

Silicon Bridge is the classic thrilling game of chance! All you need to do is cross the bridge that's made up of two rows of silicon tiles. But be careful! At each step, one of the two silicon tiles will not support your weight! Can you make it to the end?

That's what the description on this game cartridge says, but Silicon Bridge seems impossible. Maybe there's some way to cheat?

We're given a program to run locally with the following setup:

$ echo 'sun{fake_local_flag}' > flag.txt
$ runpeg --plugin SiliconBridge.peg [--debug] [--verbose] [--trace]

Upon running the program, we see what appears to be a variant of the Glass Stepping Stones challenge from Squid Game:

$ ./runpeg --plugin SiliconBridge.peg
Enter cheat code?

Welcome to the Silicon Bridge game!
|# # # #|
| # # # |
|# # # #|
|   |   |
|[ ]|[ ]|
|   |   |
|[ ]|[ ]|
|   |   |
|[ ]|[ ]|
|   |   |
|[ ]|[ ]|
|   |   |
|[ ]|[ ]|
|   |   |
|[ ]|[ ]|
|   |   |
|[ ]|[ ]|
|   |   |
|[ ]|[ ]|
|   |   |
|[ ]|[ ]|
|   |   |
|[ ]|[ ]|
|   |   |
|[ ]|[ ]|
|   |   |
|[ ]|[ ]|
|   |   |
|[ ]|[ ]|
|   |   |
|[ ]|[ ]|
|   |   |
|[ ]|[ ]|
|   |   |
|   @   |
Pick a silicon panel to jump forwards to. [L/R]?

The essence of the game is to pick the right path, left or right, at each step successfully until you reach the end. There also seems to be a place to input a cheat code, but we'll get to that shortly.

I ended up running the program several times to determine the correct path, which seemed to be LLLRLRRRRLRLRRL, but no matter which path you choose at the last step, the game will fail you.

finding the cheat code

I decided to try and detemine what the cheat code is supposed to be to see what functionality it might unlock.

We can run the Pegasus program with --trace to print each instruction as they get executed. Doing so pauses the program when it tries to run RDB A3, (0) at 0x0131.

In a debugger, after we enter any character, it branches out to another routine:

Thread state:
   (ZERO)R0: 0000      (S1)R8: 0000
     (A0)R1: FAF4      (S2)R9: 0000
  (RV/A1)R2: 000B  (FP/S3)R10: FB00
 (RVX/A2)R3: 0000     (SP)R11: FAF4
     (A3)R4: 0055     (RA)R12: 0489
     (A4)R5: 0000     (RD)R13: 0000
     (A5)R6: EA2A     (PC)R14: 011D
     (S0)R7: 0000    (DPC)R15: 0000
FLAGS: zspcvm
(dbg) dis 200
011D.0000: INC     A2, 1
011F.0000: STB     [A0], A3
0121.0000: INC     A0, 1
0123.0000: CMP     A3,  0xA
0127.0000: MOV.EQ  A1, A2
0129.0000: BRA.EQ  RD, RA
012B.0000: CMP     A2,  A1
012D.0000: MOV.GE  A1, A2
012F.0000: BRA.GE  RD, RA
0131.0000: RDB     A3, (0)
0133.0000: BRR.LT  0xFFE7
0136.0000: MOV     A1, A2
0138.0000: BRA     RD, RA
013A.0000: PSH     {A0, RA-RD}
013D.0000: RDB     A0, (15)
013F.0000: WRB.LT  (0), A0
0141.0000: BRR.LT  0xFFF9
0144.0000: POP     {A0, PC-DPC}
0147.0000: ADD.EQ  ZERO, 0xE800
014B.0000: BRR.NE  0xDFA0

This routine essentially reads up to 13 (0xB) additional characters from the user and stores them in a buffer starting at 0xFAF4.

(dbg) hexdump r 0xfaf4 16
Read violation at 0xFB00
faf4: 6865 7920 7468 6572 653a 2900            hey there:).

After reading all of the data, the routine jumps to a comparison routine:

Thread state:
   (ZERO)R0: 0000      (S1)R8: 0000
     (A0)R1: 043F      (S2)R9: 0000
  (RV/A1)R2: FAF4  (FP/S3)R10: FB00
 (RVX/A2)R3: 000B     (SP)R11: FAF4
     (A3)R4: 000A     (RA)R12: 0493
     (A4)R5: 0000     (RD)R13: 0000
     (A5)R6: EA2A     (PC)R14: 040B
     (S0)R7: 0000    (DPC)R15: 0000
(dbg) dis 20
040B.0000: LDB     A2, [A0]
040D.0000: INC     A0, 1
040F.0000: LDB     A3, [A1]
0411.0000: INC     A1, 1
0413.0000: AND     ZERO, A2, 0x80
0418.0000: BRR.EQ  0xD
041B.0000: AND     A2, 0xFF7F
041F.0000: SUB     A2, A3
0421.0000: BRR.EQ  0xFFE7
0424.0000: MOV     A1, A2
0426.0000: BRA     RD, RA
0428.0000: SUB     A1, A2, A3
042B.0000: BRA     RD, RA

This routine compares the buffer at 0x043F with our input at 0xFAF4. The string in 0x043F is what the file format describes as a "length encoded" string. Each byte in a string has its MSB set to 1, and the string continues up to and including a byte with its MSB set to 0.

Running hexdump r 0x043F 16 to get this string, and clearing the MSB yields "UUDDLRLRBA\n", which is the Konami Code.

Entering this cheat code into the program displays some new options now:

$ ./runpeg --plugin SiliconBridge.peg
Enter cheat code?
Address to patch?
Byte to replace with?

It seems that we can choose an address within the program and patch it to be a different byte value! Given that we're unable to proceed with the stepping challenge at the last level, there is probably some conditional jump near the end that ought to be inverted to let us through. Let's find it!

finding the address to patch

I decided to run the program with --trace to see at which point the program branches into a losing situation.

0329.0000: RDB     A1, (0)
L                                   <-- my input
032B.0000: HLT.GE
032C.0000: CMP     A1,  0x4C        <-- check for input 'L' (0x4C)
0330.0000: MOV.EQ  A1, ZERO
0332.0000: BRR.EQ  0x9
033E.0000: RDB     A2, (0)          <-- another read
0340.0000: HLT.GE
0341.0000: CMP     A2,  0xA         <-- check for '\n'
0345.0000: HLT.NE
0346.0000: ADD     A2, PC, 0xFDFF
034B.0000: LDW     A2, [A2]
034D.0000: SRU     A2, S0
034F.0000: AND     A2, 0x1
0353.0000: CMP     A2,  A1
0355.0000: BRR.NE  0x12
0358.0000: ADD     A3, PC, 0x2A3
035D.0000: LDW     A4, [A3]
035F.0000: INC     A4, 1
0361.0000: STW     [A3], A4
0363.0000: INC     S0, 1
0365.0000: CMP     S0,  S1
0367.0000: BRR.LT  0xFFB4
036A.0000: CMP     S0,  S1
036C.0000: BRR.GT  0xB
036F.0000: ADD     A0, PC, 0xFF7F
0374.0000: FCR     0xFD89           <-- appears to be a print routine
0100.0000: MOV     A0, A0
0102.0000: BRA.EQ  RD, RA
0104.0000: MOV     A3, 0x7F
0108.0000: LDB     A1, [A0]
010A.0000: INC     A0, 1
010C.0000: AND     A2, A1, A3
010F.0000: WRB     (0), A2          <-- prints some character
/0111.0000: CMP     A1,  A2         <-- printed '/'

I had to scroll up a lot to find the last character I entered since the Pegasus printing routines are a bit lengthy, but it seems that the branch that takes us out of the input checks and into a routine that prints /You Died/ happens at 0x036C. We would need to invert the condition of the BRR.GT instruction to be BRR.LE to let us pass through.

I had trouble trying to figure out how the opcode BRR.LE should be formatted, since the provided architecture reference didn't specify the opcode format clearly enough, as trying different byte combinations didn't seem to work.

Looking through the conditions table in the reference showed me that a GE condition will execute if the flags C=1 and Z=0. Since I had access to a few other well-formatted BRR instructions, I could try ones that set C=0 or Z=1. BRR.EQ works on Z=1 and BRR.LT works on C=0, so I decided to try those.

(dbg) hexdump r 0x0332 1
0332: 15                                       .
(dbg) c
Enter cheat code?
Address to patch?
Byte to replace with?
Welcome to the Silicon Bridge game!

// output omitted

You Win!

Success! Using these inputs on the live challenge gives us the flag: sun{l3t5_w4tch_4n_4ct10n_R3pl4y_0f_th4t_G4m3_5h4rk!}

PEG_AUTOREV (Pegasus, 500pts, 6 solves)

Now you need to connect this console to the internet to see if it can talk to the old game services! Unfortunately, because it's such an old device, you need to help it figure out how to route its way through the Internet (basically a series of tubes) to reach your ISP. Oh, and also, your ISP uses advanced network security that automatically randomizes the path of connection packets every 30 seconds! Can you get this old console connected through all 5 network hops needed to achieve Internet access before the paths are randomized again? It might be difficult with no pixels to look at...

We're only given an endpoint to connect to. Connecting to it displays this information:

||| Challenge level 1/6 |||
You have 30 seconds to solve this level.
Here comes a PEGASUS program (reference id: enominode_yusabubipo) on the next line. Size: 4238 bytes (encoded as hex).
Running now...

It seems that we're going to need to receive the program, run it, and solve it 6 times. Downloading one of the programs and running it shows no visible output, but we can notice a few interesting things in a debugger:

(dbg) context

Thread state:
   (ZERO)R0: 0000      (S1)R8: 0000
     (A0)R1: 0000      (S2)R9: 0000
  (RV/A1)R2: 0000  (FP/S3)R10: FB00
 (RVX/A2)R3: 0000     (SP)R11: FB00
     (A3)R4: 0000     (RA)R12: FFFF
     (A4)R5: 0000     (RD)R13: FFFF
     (A5)R6: EA2A     (PC)R14: 0100
     (S0)R7: 0000    (DPC)R15: 0000
FLAGS: Zspcvm

Next instructions:
0100.0000: MOV     DPC, 0x30
0104.0000: MOV     PC, RA
0106.0000: MOV     PC, RA
0108.0000: MOV     PC, RA
010A.0000: MOV     PC, RA

The very first instruction moves 0x30 into the DPC register, which is the "delta program counter". According to the architecture reference, the DPC register modifies how much the PC register gets incremented by on each instruction execution. After the first instruction, we can get a much more coherent disassembly listing:

0104.0030: MOV     A1, 0x4
01C8.0030: MOV     A2, 0x0
028C.0030: MOV     A3, 0x0
0350.0030: RDB     A0, (0) <---------+
03B2.0030: CMP     A0,  0x30         |      <-- checks for input '0'
0476.0030: BRR.EQ  0x2DF --------+   |
0509.0030: CMP     A0,  0x31     |   |      <-- checks for input '1'
05CD.0030: BRR.EQ  0x498 --------|-+ |
0660.0030: CMP     A0,  0xA      | | |      <-- checks for input '\n'
0724.0030: BRR.EQ  0xFB99 -------|-|-+      <-- re-prompts for input
07B7.0030: HLT                   | |
07E8.0030: ADD     A4, A2, A1 <--+ |
087B.0030: CMP     A4,  0x7        |        <-- checks A1 + A2 < 7
093F.0030: HLT.GE                  |
0970.0030: ADD     A0, PC, 0xF69F  |
0A65.0030: ADD     PC, A0, A4      |
0AF8.0030: ADD     A4, A3, A1 <----+
0B8B.0030: CMP     A4,  0x7                 <-- checks A1 + A2 < 7
0C4F.0030: HLT.GE
0C80.0030: MLU     A0, A4, 0x7
0D75.0030: ADD     A4, PC, 0xF29A
0E6A.0030: ADD     PC, A0, A4               <-- onto the next stage

This routine prompts the user to enter a 0 or a 1. Based on this choice, the program will make a comparison between 7 and the sum of A1+A2 or A1+A3 respectively. If the sum is less than 7, we can progress to the next challenge stage.

It seems that the initial values set for A1, A2, and A3 vary between programs, and the values in later stages also vary. A strategy to try and bypass would be to just pick the minimum of A1+A2 and A1+A3 for each branch.

At this point in time, my teammate rik was able to script up a solution to solve the challenge that required much less introspection into the binary: simply bruteforce a solution path locally.

If we imagine the challenge as a binary tree with nodes representing challenges, paths to children as 0 or 1 choices, and leaves representing a 'win' or 'fail', we could just perform a depth-first search, and backtrack when a path yields a failure.

We can implement this easily using pwntools by connecting to the remote instance with remote(), launch a local process() in an infinite loop until it returns a solution, then send the solution to the remote. Rinse & repeat 6 times to get the flag: sun{0ur_r0b0t1c_p3g4su5_0v3rl0rd5_w1ll_s4v3_us!pr0b4bly...}

PEG_AUTOREV2 (Pegasus, 300pts, 5 solves)

This challenge was released as a "fixed" version of PEG_AUTOREV due to the challenge author realizing that there was an unintended bruteforce solution. (Un)fortunately, this challenge suffered from the same bruteforceable solution.

The only difference in this challenge lies in the input that is required to take the different paths in the program:

0104.0030: MOV     A1, 0x3
01C8.0030: MOV     A2, 0x0
028C.0030: MOV     A3, 0x0
0350.0030: RDB     A0, (0)
03B2.0030: HLT.GE
03E3.0030: CMP     A0,  0x44  <-- checks for 'D'
04A7.0030: BRR.EQ  0x2DF
053A.0030: CMP     A0,  0x4D  <-- checks for 'M'
05FE.0030: BRR.EQ  0x498
0691.0030: CMP     A0,  0xA

Rather than checking for a '0' or '1', the program now checks for random characters at different stages. Simply reading these values from the disassembly output of the local process() and using them for the path selection was sufficient to solve this challenge, yielding the flag: sun{h0p3fully_th15_0n3_1s_h4rd3r_t0_brut3f0rc3_l0l}