// Bug Bounty Automation in Ruby · Part 1
The lab and your first probe
Stand up a deliberately vulnerable API on your own machine, then write your first Ruby client that logs in and reads a profile back. No prior Ruby needed — by the end you have a target to hunt and a script that talks to it.
This series teaches Ruby the way a bug-bounty hunter actually uses it: by building your own scanner and pointing it at a real API. Over eight parts you’ll write a toolkit that maps an API’s surface, breaks its authorization, fuzzes its inputs, wins a race against it, diffs its responses across runs, and finally packages everything into one scheduled scanner that emits a coordinated-disclosure report.
The target is a small API you run yourself: a deliberately vulnerable “Paper Markets” service, written in Ruby so you can read every bug before you exploit it. No prior Ruby needed — we build the language up as we build the tool.
Scope. Everything here points at a lab on your own machine, bound to
127.0.0.1. That is the entire point of a lab: you get to attack something you fully own. Never run these tools against a service you don’t have explicit permission to test — that’s the line between bug bounty and a crime.
Getting Ruby 4
This site runs on Ruby 4, so let’s match it. Check what you have:
$ ruby -v
ruby 4.0.6 (2025-12-25 revision ...) [x86_64-linux]
If that prints a 4.x version you’re set; if not, install it with a version
manager (rbenv install 4.0.6, mise use ruby@4.0.6, or your distro’s
package). We’ll need one gem for the lab — Sinatra, a tiny web framework:
$ gem install sinatra
Building the target
A bug-hunter reads the target before touching it. Since ours is open source —
we’re writing it — you get to see every flaw in plain Ruby. Create a file called
labapi.rb. We’ll build it in pieces; the first piece is the framework and the
data it serves:
require 'sinatra'
require 'json'
set :port, 4567
set :bind, '127.0.0.1'
USERS = {
1 => { 'id' => 1, 'name' => 'alice', 'token' => 'tok_alice', 'role' => 'user', 'balance' => 1_000 },
2 => { 'id' => 2, 'name' => 'bob', 'token' => 'tok_bob', 'role' => 'user', 'balance' => 1_500 },
99 => { 'id' => 99, 'name' => 'admin', 'token' => 'tok_admin', 'role' => 'admin', 'balance' => 0 }
}
MARKETS = {
1 => { 'id' => 1, 'title' => 'BTC above 70k at noon', 'price' => 0.62 },
2 => { 'id' => 2, 'title' => 'ETH above 4k at noon', 'price' => 0.41 },
3 => { 'id' => 3, 'title' => 'Rain in NYC tomorrow', 'price' => 0.55 }
}
ORDERS = []
COUPONS = { 'FREE100' => { 'code' => 'FREE100', 'value' => 100, 'redeemed' => false } }
USERS, MARKETS, ORDERS, and COUPONS are the whole database — held in
memory, so restarting the server resets the world. A { ... } is a hash:
keys pointing at values. A [ ] is an array: an ordered list. These two
structures carry almost everything in Ruby, and you’ll use them constantly.
Now the plumbing. Add a couple of helpers — small methods every route can call:
helpers do
def json(obj)
content_type :json
obj.to_json
end
def current_user
auth = request.env['HTTP_AUTHORIZATION'].to_s
token = auth.sub(/\ABearer\s+/, '')
USERS.values.find { |u| u['token'] == token }
end
end
json sets the response type and serializes any object to JSON. current_user
reads the Authorization header, strips the Bearer prefix, and looks up the
matching user — returning nil if the token is unknown. find walks the list
and hands back the first element for which the block is true.
Now the routes. Each get/post block is one endpoint. Read the comments —
every one marked BUG is something you’ll exploit in a later part:
post '/login' do
body = JSON.parse(request.body.read) rescue {}
user = USERS.values.find { |u| u['name'] == body['name'] }
halt 401, json('error' => 'no such user') unless user
# BUG: the password is never checked.
json('token' => user['token'])
end
get '/api/me' do
u = current_user or halt 401, json('error' => 'unauthenticated')
json(u)
end
# BUG: mass assignment — the whole body is merged into your own record, so a
# client can set fields it should never control (role, balance...).
patch '/api/me' do
u = current_user or halt 401, json('error' => 'unauthenticated')
body = JSON.parse(request.body.read) rescue {}
u.merge!(body)
json(u)
end
# BUG: returns any user by id with no ownership check (BOLA / IDOR).
get '/api/users/:id' do
current_user or halt 401, json('error' => 'unauthenticated')
u = USERS[params['id'].to_i] or halt 404, json('error' => 'not found')
json(u)
end
get '/api/markets' do
json('markets' => MARKETS.values)
end
get '/api/markets/:id' do
m = MARKETS[params['id'].to_i] or halt 404, json('error' => 'not found')
json(m)
end
# BUG: mass assignment — the whole body is merged into the order, so a
# client can set fields it should never control (user_id, balance...).
post '/api/orders' do
u = current_user or halt 401, json('error' => 'unauthenticated')
body = JSON.parse(request.body.read) rescue {}
order = { 'id' => ORDERS.size + 1, 'user_id' => u['id'] }.merge(body)
ORDERS << order
json(order)
end
# BUG: no role check — any authenticated user reaches admin-only stats.
get '/api/admin/stats' do
current_user or halt 401, json('error' => 'unauthenticated')
json('users' => USERS.size, 'orders' => ORDERS.size,
'balances' => USERS.values.sum { |u| u['balance'] })
end
get '/api/search' do
q = params['q'].to_s
json('query' => q, 'results' => MARKETS.values.select { |m| m['title'].include?(q) })
end
# BUG: check-then-act with no lock — two requests can both pass the
# `redeemed` check before either flips it, so a coupon pays out twice.
post '/api/redeem' do
u = current_user or halt 401, json('error' => 'unauthenticated')
body = JSON.parse(request.body.read) rescue {}
c = COUPONS[body['code'].to_s] or halt 404, json('error' => 'no such coupon')
halt 409, json('error' => 'already redeemed') if c['redeemed']
sleep 0.05 # widen the race window so the bug is easy to see
u['balance'] += c['value']
c['redeemed'] = true
json('balance' => u['balance'])
end
# BUG: a debug endpoint left in prod and left out of the spec — it dumps
# everything, tokens included. Exactly what content discovery turns up.
get '/api/debug' do
json('users' => USERS.values, 'orders' => ORDERS, 'coupons' => COUPONS.values)
end
get '/openapi.json' do
json(
'paths' => {
'/login' => %w[post],
'/api/me' => %w[get],
'/api/users/{id}' => %w[get],
'/api/markets' => %w[get],
'/api/markets/{id}' => %w[get],
'/api/orders' => %w[post],
'/api/search' => %w[get],
'/api/redeem' => %w[post]
}
)
end
Don’t worry about every line yet — later parts return to each bug in turn. Start the server:
$ ruby labapi.rb
== Sinatra (v4.2.1) has taken the stage on 4567 for development
Leave it running in that terminal. In a second terminal, confirm it answers:
$ curl -s http://127.0.0.1:4567/api/markets
{"markets":[{"id":1,"title":"BTC above 70k at noon","price":0.62}, ...]}
That’s your target, live and yours.
Your first probe
curl is fine for one request, but a scanner needs to send hundreds and read
the answers programmatically. That’s Ruby’s job. Open a new file, probe.rb.
Ruby’s standard library ships a full HTTP client in net/http — no gem needed:
require 'net/http'
require 'json'
require 'uri'
BASE = 'http://127.0.0.1:4567'
def post_json(path, data)
uri = URI("#{BASE}#{path}")
req = Net::HTTP::Post.new(uri)
req['Content-Type'] = 'application/json'
req.body = data.to_json
Net::HTTP.start(uri.host, uri.port) { |http| http.request(req) }
end
def get_json(path, token = nil)
uri = URI("#{BASE}#{path}")
req = Net::HTTP::Get.new(uri)
req['Authorization'] = "Bearer #{token}" if token
Net::HTTP.start(uri.host, uri.port) { |http| http.request(req) }
end
Two methods, defined with def. Each builds a request, opens a connection
with Net::HTTP.start, and returns the response. URI("...") parses a string
into something with .host and .port. The token = nil gives the argument a
default, so get_json('/api/markets') works without one. "#{BASE}#{path}"
is string interpolation — #{...} drops a value into the string.
Now use them. Log in as alice, then read her profile with the token we get back:
# Log in as alice and read our own profile.
res = post_json('/login', 'name' => 'alice', 'pass' => 'hunter2')
token = JSON.parse(res.body)['token']
puts "[+] token: #{token}"
me = get_json('/api/me', token)
puts "[+] me: #{me.body}"
JSON.parse turns the response body (a string) into a Ruby hash, and
['token'] reads one key out of it. Run it against the live lab:
$ ruby probe.rb
[+] token: tok_alice
[+] me: {"id":1,"name":"alice","token":"tok_alice","role":"user","balance":1000}
You logged in, got a token, and used it to read a profile — the whole request /
response loop every later part builds on. Notice something already: we sent the
password hunter2 and it worked, but so would any password. That’s the first
bug, and we’ll come back to it.
Takeaways
- A lab is a target you fully own. Ours is a Ruby/Sinatra API bound to
127.0.0.1; keep it running in its own terminal. Never point these tools at anything you’re not authorized to test. - Ruby’s core data structures are the hash (
{ key => value }) and the array ([ ]). The lab’s whole database is a few of each. net/httpis a complete HTTP client in the standard library:Net::HTTP::Get/Post, set headers likereq['Authorization'], send withNet::HTTP.start { |http| http.request(req) }.defdefines a method; arguments can have defaults (token = nil);"#{...}"interpolates;JSON.parseturns a response body into a hash you can index with['key'].
Exercises
Get your hands on the loop before moving on:
- Add a
GET /api/marketscall toprobe.rband print how many markets came back (JSON.parse(res.body)['markets'].size). - Log in as
bobinstead and print his balance from/api/me. Then try a name that doesn’t exist and print the response’s.code— confirm it’s401. - Request
/openapi.jsonandputsthe list of paths. You’ve just done your first piece of recon; Part 2 turns it into a map.
Next — Part 2: we take that openapi.json, plus a wordlist, and write a
mapper that inventories every endpoint the API exposes — including the ones the
spec doesn’t mention.