Who doesn’t like ROP? Let’s try some new features introduced in 2.3.
ROP is a simple Ruby VM crackme. We are given a file rop.iseq, without any other detail.
$ file rop.iseq
rop.iseq: data
Opening it with an hex editor, we see that it starts with YARB
, and from its
contents we can guess that it is a sort of bytecode format. With Google’s help,
we come across Ruby’s RubyVM::InstructionSequence
class, and in particular
the load_from_binary
method, introduced in Ruby 2.3.
Its purpose is “to load an iseq object from binary format String object
created by #to_binary“… and from Ruby’s source code (compile.c
),
we see that the binary file it loads must start with the bytes YARB
…
so it really seems that we are dealing with serialized Ruby bytecode!
We can simply load and execute this file with
RubyVM::InstructionSequence
.
I had to download the Ruby interpreter from rvm
, as the one coming with
Ubuntu 16.04 had a value of the constant RUBY_PLATFORM
slightly different
than the one of the iseq file, causing a unmatched platform
error when
loading the iseq.
The challenge waits for some input; trying to enter a string, it outputs
Invalid Key @_@
: we need to figure out a valid input.
We can also dump the
disassembly:
We could not find (quickly) much documentation about the Ruby virtual machine, or tools working with the iseq format, so we resorted to guess the behavior of the various opcodes (it is a simple stack-based VM) to understand what the program is doing.
Fortunately, the bytecode contains various trace
instructions; we made use of
set_trace_func
to print those events during the program execution. This way,
we could understand at which line of code the program was jumping to the function
gg
- and, thus, what components of our input string were correct!
From the disassembly, we see that the input is split into 5 substrings
separated by '-'
. Each of them has to match the regexp
/^[0-9A-F]{4}$/
, and is checked by a separate block of code. As soon as a
substring does not satisfy a condition, the program calls the function gg
,
which outputs "Invalid Key @_@"
and exits. Thus, we have to pass five checks,
one for each substring.
Just to give an idea of how the disasm looked like, here is the first check:
0109 trace 1 ( 42)
0111 getlocal_OP__WC__0 2
0113 putobject_OP_INT2FIX_O_0_C_
0114 opt_aref <callinfo!mid:[], argc:1, ARGS_SIMPLE>, <callcache>
0117 putobject 16
0119 opt_send_without_block <callinfo!mid:to_i, argc:1, ARGS_SIMPLE>, <callcache>
0122 putobject 31337
0124 opt_eq <callinfo!mid:==, argc:1, ARGS_SIMPLE>, <callcache>
0127 branchif 134
0129 putself
0130 opt_send_without_block <callinfo!mid:gg, argc:0, FCALL|VCALL|ARGS_SIMPLE>, <callcache>
0133 pop
(getlocal_OP__WC__0 2
is the variable xs
, an array containing the user input split at '-'
)
Once understood the disassembled Ruby VM code, passing the two checks was trivial. Below a rough Ruby version of the five checks:
For the third check, instead of thinking about whether it was possible to
invert f
, we considered that the input space is very small (four digit hex
numbers), and we resorted to bruteforce:
This Python script took 0.263s on my laptop to find the correct string.
Finally, for the last check, we noticed that
947d46f8060d9d7025cc5807ab9bf1b3b9143304
is the SHA-1 of 5671, so we get the
last component of the input string by just XOR-ing the first four ones with
- The complete input string is
7A69-ECAF-1BD2-5141-CA72
which gives us the output
Congratz! flag is hitcon{ROP = Ruby Obsecured Programming ^_<}
For completeness, here is the complete Ruby source code of the crackme - as we were able to manually reverse:
Let us know what you think of this article on twitter @towerofhanoi or leave a comment below!