prettify code

2017年11月7日 星期二

[Official Write-up] HITCON CTF 2017 - pwn327 Real Ruby Escaping


The attachment can be found in my repository: link

Many people ask me to give this writeup because I announced that all the 11 teams used unintended solution on IRC. Now comes the intended one :D

This task asks you to exploit the ruby interpreter, given arbitrary ruby code execution but protected by seccomp rules.

The service code (server.rb):

require __dir__ + '/sandbox/sandbox'

$stdout.sync = true

proc {
  my_exit = Kernel.method(:exit!)
  my_puts = $stdout.method(:puts)
  ObjectSpace.each_object(Module) { |m| m.freeze }
  set_trace_func proc { |event, file, line, id, binding, klass|
    bad_id = /`|exec|foreach|fork|load|method_added|open|read(?!line$)|require|set_trace_func|spawn|syscall|system/
    bad_class = /(?<!True|False|Nil)Class|Module|Dir|File|ObjectSpace|Process|Thread/
    if event =~ /class/ || (event =~ /call/ && (id =~ bad_id || klass.to_s =~ bad_class)) "\e[1;31m== Hacker Detected (#{$&}) ==\e[0m"

loop do
  # line = Readline.readline('real> ', true) # this uses sysopen..
  print 'real> '
  line = gets
  puts '=> ' + eval(line, TOPLEVEL_BINDING).inspect

And the ruby binary is Full RELRO / NX / PIE. installed the seccomp rules, which forbid syscalls with number greater than 199, and a blacklist of some syscalls. The most important is the open syscall is forbidden!

But the seccomp rules doesn't check the architecture running. Now the target is clear: we need exploit ruby to arbitrary shellcode execution, then use the 32-bit open syscall to read the flag.

Unintended Solution

As I said before, all the 11 teams used unintended solution that bypassing the set_trace_func method. After bypassing it, you can use the powerful method syscall, which can read / write memory easily:

syscall accepts integral arguments as pointers, so using read / write syscall directly leads to write / read memory, respectively.

Update (2017/11/12): If you are interested in how to bypass the tracing, you can see this writeup by team 0x0C113F04.

Intended Solution

Time for the intended solution.
I use the method IO#ioctl, and here's its document:
ioctl(integer_cmd, arg) → integer
Provides a mechanism for issuing low-level commands to control or query I/O devices. Arguments and results are platform dependent. If arg is a number, its value is passed directly. If it is a string, it is interpreted as a binary sequence of bytes. On Unix platforms, see ioctl(2) for details. Not implemented on all platforms.
Yes, this method has the same behavior as method syscall, treats integers as pointers. I saw this document and started to find when will ioctl do read / write in memory - and I found it.

Here is the list of valid first argument of ioctl. I use the TIOCGWINSZ/TIOCSWINSZ pair to play with memory. These two arguments are used for getting / setting current window size. My leak method is defined as follows, which can leak 8 bytes given target address.
leak = lambda do |addr|
  s = 'A' * 8
    STDIN.ioctl(TIOCSWINSZ, addr)
    return 0
  s[0, 8].unpack("Q*")[0]

And the write method is easier:
write = lambda do |addr, val|
  STDIN.ioctl(TIOCSWINSZ, [val].pack("Q*"))
  STDIN.ioctl(TIOCGWINSZ, addr)


With arbitrary memory read / write, I'm sure you can pwn the ruby binary. Following is my exploitation flow.

  1. "".__id__ * 2 would be a heap address (it's a feature!)
  2. Scan heap to leak any library's address
  3. Leak stack address via glibc._libc_argv
  4. Write ROP payload to do mprotect and write shellcode that mmap / read on the mprotect-ed page
  5. Write 32-bit open shellcode to the mmapped page
  6. Gotcha!
See my exploit script in my repo for details ;)

Fun Facts


Actually bypassing and using Kernel.syscall became intended solution right before this challenge being released. If you participated in this CTF you might notice that the easier challenge (Baby Ruby Escaping) was released 6 hours before this (Real) one. We want to know if anyone can bypass the tracing - and of course the answer is yes.

So it became our challenge, should we harden the tracing? Or just let teams exploit it with the easy way?

I chose the second option for three reasons:
  1. My solution is really hard to find, my teammate can't solve this challenge even I told him it's a documented feature and related to IO.
  2. There're too many hard pwnables, let this be a medium-level one :p
  3. (Main reason) No matter how we harden the tracing, it still might be bypassed.
So this challenge wasn't modified even we already knew teams can use Kernel.syscall to solve it.

Bypassing the tracing

The tracing sandbox had been modified 10+ versions (We had a tiny atk&def on it :p). And the final version you saw is the unbreakable one - to us.
While during the competition we got at least three kinds of bypassing strategy. Good job, you guys are really amazing!

Post Script

Thanks for reading, wish you guys had a fun time with Ruby 😛