team-logo
Published on

SunshineCTF - pegasus challenge

Authors

Introduction

Between September 27 and 29 a small but interesting US CTF took place (SunshineCTF). In addition to the usual PWN, reverse, and crypto tasks, the event included i95 (an easier PWN-style challenge) and the Pegasus challenge set. We solved two Pegasus tasks. The third one might also have been solvable, but these tasks were nontrivial and took some time. This write-up focuses on the Pegasus problems.

PEGASUS

img PEGASUS is a custom file format created for the CTF. The authors describe it as:
  • Portable
  • Executable
  • Generic
  • Architecture
  • Supporting
  • Unusual
  • Systems

See the "EAR Essential Architecture Reference v3" for details. For example, EARv3 is documented to use 8-bit bytes, a 16-bit virtual address space, and 24-bit physical addressing

The authors ship a tool called runpeg that runs Pegasus programs and also provides a debugger, disassembler and tracer — a very capable utility. But at first I tried debugging with real pc gdb:

gdb --args ./runpeg ./AccessCode.peg

but that was awkward. The runpeg options proved much more convenient:

runpeg [-dhkltuv] [OPTIONS] input1.peg {inputN.peg...}

Options:
    -t, --timeout <seconds>      Max number of seconds to run before exiting
            --bootrom <filepath>     Path to the bootrom image to use (flat binary or PEGASUS file)
            --plugin <filepath>      Path to a plugin library to load as a checker module
            --plugin-arg <keyval>    Format like 'key=value', will be passed to all loaded checker modules
            --function <funcname>    Resolve the named symbol and call it as a function
    -d, --debug                  Enable the EAR debugger
            --debug-noninvasive      Enable the EAR debugger in non-invasive mode
    -k, --kernel-debug           Enable kernel debugging
            --trace                  Print every instruction as it runs (only usermode)
            --kernel-trace           Print every instruction as it runs (both usermode and kernelmode)
    -u, --uart                   Show output written to port 0xD (kernel debug UART)
    -v, --verbose                Enable verbose mode for EAR emulator
            --input-fd <fd>          Use a different file descriptor as port 0 input
            --output-fd <fd>         Use a different file descriptor as port 0 output
    -l, --io-listen <sockpath>   Use the path to a UNIX domain socket for port 0 input/output
            --io-quiet               Don't print an info message when listening for an incoming connection

runpeg also includes a hex editor that lets you view emulator memory. The editor does not allow direct in-place memory edits, but you can attach to the running process with pwndbg using the process ID (pwndbg -p <pid>) to modify memory as needed.

The following sections describe how we reverse-engineered the EAR/PEGASUS architecture and the techniques used to solve the challenges.


Can you hEAR me?

img

Writeup author: michalBB

The first task is fairly simple — just run the Pegasus emulator and you'll get the flag. However, you must place all required libraries into the directory.

LD_LIBRARY_PATH=. ./runpeg CanYouHearMe.peg
RAD-EAR-3 CPU initialized, running at 10MHz

Board serial number: sun{d0_n0t_fEAR_th1s_c4t3g0ry}

sun{d0_n0t_fEAR_th1s_c4t3g0ry}

Access Code

img

Writeup author: kerszi

The beginning

This task is much more difficult. It was a typical reverse engineering challenge — you had to enter the correct password. After three attempts, you receive a hash. The first thing you should do after running the program (which I didn't do) is read the documentation, as it really helps a lot. I simply ran the binary in pwngdb and debugged it with pwn --args ./runpeg AccessCode.peg. Of course, this worked, but I didn't find anything interesting. There were no useful strings in memory either. Then I tried ltrace ./runpeg AccessCode.peg, which showed me something interesting.

...
write(1, "s", 1s)                                                                          = 1
write(1, "s", 1s)                                                                          = 1
write(1, " ", 1 )                                                                          = 1
write(1, "c", 1c)                                                                          = 1
write(1, "o", 1o)                                                                          = 1
write(1, "d", 1d)                                                                          = 1
write(1, "e", 1e)                                                                          = 1
write(1, ":", 1:)                                                                          = 1
write(1, "\n", 1
)                                                                         = 1
write(1, ">", 1>)                                                                          = 1
write(1, " ", 1 )                                                                          = 1
...

This showed that the strings are displayed one character at a time. I was also surprised that there were no message strings in the binary, but if I had read the documentation, I would have known that the first bit in the text was enabled, which obfuscated all the information. It would have been enough to disable the first bit in every byte throughout the binary to find a few more strings. As it was, I didn't even know what to enter—whether the whole flag or just the password—and then I would have seen the flag.

Strings #1

I loaded the binary into 010 editor and noticed some interesting strings: n{ and su, which could combine to form sun{. That probably wasn't a coincidence. The password might have been the flag, and that's what I assumed. alt text

Hash

I also tried to crack the hash, but it wasn't possible; later I found out it wasn't sha-256. However, I was curious whether the hash was generated or just hardcoded. I checked this by changing the letter s at 0x5f6 in the binary to something else and running the program. I got a different hash, which confirmed that the hash was generated.

Strings #2

I wanted to see how the instructions were executed in the debugger, so I disassembled the entire code step by step. Fortunately, runpeg has a --trace option, which lets you observe the code execution, and bingo I found 0x7573, which corresponds to us. The visible 0x7B6E is {n. This already forms the beginning of the flag sun{. Remember when I mentioned editing the letter s at address 05F6? Here, too, is that address and the same letter, just shifted by 0x100 forward. So everything matches so far...

	06EE.0000:   MOV     A0, S0
	06F0.0000:   MOV     A2, 0x7B6E
	06F4.0000:   MOV     A1, 0x7573
	06F8.0000:   PSH     {A1-A2}
	06FB.0000:   MOV     A1, SP
	06FD.0000:   MOV     A2, 0x4
	0701.0000:   FCR     @gimli_absorb

I thought I could find more letters, so I kept searching. Next, there was 0x6874 and 0x5F33, which together formed th3_:

        06C0.0000:   MOV     A1, 0x6874
        06C4.0000:   MOV     A2, 0x5F33
        06C8.0000:   PSH     {A1-A2}

The last fragment I found using this method was the end of the flag, EAR}. Although at this point, the values started to get mixed in A3. See?

        0734.0000:   RDC     A3, INSN_COUNT_LO
        0736.0000:   RDC     A4, INSN_COUNT_HI
        0738.0000:   ADD     A3, 0x280F
        073C.0000:   ADD     A4, 0x7D52
        0740.0000:   PSH     {A3-A4}
        0743.0000:   MOV     A0, S0
        0745.0000:   MOV     A1, SP
        0747.0000:   MOV     A2, 0x4
        074B.0000:   FCR     @gimli_absorb

But what about the rest? Well, I had to start debugging. First, I needed to run the program in debug mode: ./runpeg ./AccessCode.peg -d. After many attempts and blind searching, I set a breakpoint at 0x04E9, which is @gimli_absorb_byte, then continued with c and checked what changed in (A1).

(dbg) b @gimli_absorb_byte
Created breakpoint #1 at address 04E9 (X)
(dbg) c
HW breakpoint #1 hit trying to execute 1 byte at 04E9
A breakpoint was hit

Thread state:
        (ZERO)R0: 0000      (S1)R8: FD97
          (A0)R1: 0A00      (S2)R9: 0004
          (A1)R2: 0073     (FP)R10: FD90
          (A2)R3: 0004     (SP)R11: FD8A
          (A3)R4: 0000     (RA)R12: 053B
          (A4)R5: 0000     (RD)R13: 0000
          (A5)R6: EA23     (PC)R14: 04E9 //@gimli_absorb_byte+0
          (S0)R7: 0A00    (DPC)R15: 0000
FLAGS: zspcvXr

Next instructions:
@gimli_absorb_byte:
                  04E9.0000: LDW     A2, [A0 + 0x30]
                  04EE.0000: ADD     A2, A0
                  04F0.0000: LDB     A3, [A2]
                  04F2.0000: XOR     A3, A1
                  04F4.0000: STB     [A2],A3
(dbg)

As you can see, A1 contains 0x73, which is the letter s. I thought I was about to get the flag. I continued like this a dozen times, but then the pattern started to break down. The worst part was that I couldn't inspect the memory fragment. There was an xxd command, but I couldn't see the whole thing. What did I do? I Checked pid of ./runpeg

kerszi     14890  0.0  0.0   2928  1536 pts/6    S+   22:11   0:00 ./runpeg ./AccessCode.peg -d

and launched pwngdb in another console with the process running AccessCode using the command:

gdb -p 14890
As you can see in the screenshot, I was able to view a longer piece of text, but then the text started to break up and the flag began to disappear. I had to find another method. alt text

The Flag

I didn't know what else to do. I tried brute-forcing the missing letters with Hashcat, but that didn't work. ChatGPT tried to help, but ended up causing more confusion, suggesting endings like fEAR} and other nonsense. I zeroed out the first bit in the entire binary and found a fragment at the end of the program: 4r7_15_no. The o wasn't encoded and messed up the fragment, so it should have been 4r7_15_n. I thought maybe I should set a breakpoint at @gimli_absorb, which is address 0x0503. That turned out to be the right approach. Thanks to this, I FINALLY got the flag.

EAR debugger
(dbg) b 0503
Created breakpoint #1 at address 0503 (X)
(dbg) c
HW breakpoint #1 hit trying to execute 1 byte at 0503
A breakpoint was hit

Thread state:
   (ZERO)R0: 0000      (S1)R8: FD97
     (A0)R1: 0A00      (S2)R9: 0004
     (A1)R2: 0073     (FP)R10: FD90
     (A2)R3: 0A00     (SP)R11: FD8A
     (A3)R4: 0073     (RA)R12: 0540
     (A4)R5: 0000     (RD)R13: 0000
     (A5)R6: EA23     (PC)R14: 0503 //@gimli_advance+0
     (S0)R7: 0A00    (DPC)R15: 0000
FLAGS: zspcvXr

Next instructions:
@gimli_advance:
        0503.0000: ADD     A1, A0, 0x30
        0508.0000: LDW     A2, [A1]
        050A.0000: INC     A2, 1
        050C.0000: STW     [A1],A2
        050E.0000: CMP     A2, 0x10
(dbg)

.....

(dbg) c
HW breakpoint #1 hit trying to execute 1 byte at 0503
A breakpoint was hit

Thread state:
   (ZERO)R0: 0000      (S1)R8: FD9A
     (A0)R1: 0A00      (S2)R9: 0001
     (A1)R2: 007D     (FP)R10: FD90
     (A2)R3: 0A08     (SP)R11: FD8A
     (A3)R4: 00B2     (RA)R12: 0540
     (A4)R5: 7D52     (RD)R13: 0000
     (A5)R6: 0DC8     (PC)R14: 0503 //@gimli_advance+0
     (S0)R7: 0A00    (DPC)R15: 0000
FLAGS: zspcvXr

Next instructions:
@gimli_advance:
        0503.0000: ADD     A1, A0, 0x30
        0508.0000: LDW     A2, [A1]
        050A.0000: INC     A2, 1
        050C.0000: STW     [A1],A2
        050E.0000: CMP     A2, 0x10
(dbg) bp clear
Cleared all breakpoints
(dbg) c
Input security access code:
> sun{th3_fun_p4r7_15_nEAR}
Access granted!

Final

This was a very interesting task that taught me how to approach a new processor. I kept complaining about the author introducing so many rabbit holes, but I learned a lot. Good job. I didn't finish the last task of this type there simply wasn't enough time. Maybe I would have solved it, maybe not.

sun{th3_fun_p4r7_15_nEAR}

Bonus

Optionally, You can find all resources to tests: https://github.com/MindCraftersi/ctf/tree/main/2025/SunshineCTF