Feb 17, 2026·7 min read·3 visits
Active Job versions prior to 4.2.0.beta2 treated any string starting with 'gid://' as a GlobalID reference, automatically locating and instantiating the corresponding object. Attackers could exploit this to inject arbitrary database records into background jobs.
A historical but critical flaw in Ruby on Rails' Active Job component allowed attackers to perform object injection via GlobalID strings. By crafting specific string arguments containing the 'gid://' prefix, malicious actors could force the application to instantiate arbitrary Active Record models during background job processing. This 'magic' deserialization bypasses intended access controls and logic.
Ruby on Rails is famous for its "magic"—the convention-over-configuration philosophy that makes developers' lives easier but occasionally makes security researchers' lives much more interesting. One of the crown jewels of this magic is Active Job, introduced around Rails 4.2. It unified background processing, allowing devs to write jobs that run on Sidekiq, Resque, or Delayed Job without changing a line of code.
But the real magic trick was GlobalID. In the old days, if you wanted to pass a user to a background job, you passed the user_id (an integer) and fetched the record manually inside the job. Active Job said, "Nah, that's too much typing." With Active Job, you could pass the actual User object: SendWelcomeEmailJob.perform_later(user). The framework would quietly serialize this into a URI like gid://my-app/User/1 and re-materialize it into a User object when the worker picked it up.
It sounds convenient, doesn't it? The problem is that convenience in serialization often comes at the cost of strict typing. When a system tries to be too smart about converting data back into objects, it often forgets to ask: "Wait, did the user actually intend for this string to become a database query?"
The vulnerability, now tracked as GHSA-MPWP-4H2M-765C, lies in how Active Job decided what to deserialize. In the affected beta versions, the logic was dangerously simple. It operated on a principle roughly equivalent to: "If it looks like a GlobalID, it must be a GlobalID."
Specifically, the argument deserializer iterated through the arguments passed to a job. If it encountered a String that started with the prefix gid://, it immediately assumed, "Aha! This is a reference to a model!" and passed it to GlobalID::Locator.locate.
This is a classic case of improper type distinction. The system failed to differentiate between a literal string containing a GID URI and a serialized reference to an object. If a developer wrote a job meant to log a URL or a message, and an attacker managed to pass the string gid://app/User/1 into that argument, Active Job wouldn't treat it as text. It would treat it as a command to fetch User #1 from the database.
This meant any input vector that eventually flowed into an ActiveJob argument—even indirectly—became a potential vector for Object Injection. You aren't getting full RCE (Remote Code Execution) immediately like you might with Python's pickle or Java's ObjectInputStream, but you are forcing the application to instantiate objects it never intended to touch in that context.
Let's look at the logic that caused this headache. The vulnerable deserialization code effectively looked something like this (simplified for clarity):
# Vulnerable Logic (Conceptual)
def deserialize_argument(argument)
if argument.is_a?(String) && argument.start_with?("gid://")
GlobalID::Locator.locate(argument)
else
argument
end
endDo you see the issue? There is no metadata wrapping the value to say "This was an object." It just trusts the string content. If I type gid://... into a form field that gets passed to a background job, I trigger a database lookup.
The fix, implemented in version 4.2.0.beta2, introduced a structured wrapper. Instead of relying on the string value itself, Rails decided to wrap complex objects in a special hash structure during serialization. The new format looks for a specific key, often _aj_globalid.
# Secure Logic (Post-Fix)
def deserialize_argument(argument)
if argument.is_a?(Hash) && argument.key?("_aj_globalid")
GlobalID::Locator.locate(argument["_aj_globalid"])
else
argument
end
endNow, if an attacker sends the string gid://app/User/1, the deserializer sees it as just a String. It doesn't match the Hash structure required for object hydration. The magic is now contained within a specific, internal protocol rather than being applied to every string in the universe.
To exploit this, we need a scenario where user input controls an argument passed to perform_later. This is more common than you'd think in dynamic applications or those using generic "Audit" or "Notification" jobs.
Scenario: Imagine an application has a LogEventJob that takes a message string and logs it to an external service.
# Controller
def create
# User input directly passed to job
LogEventJob.perform_later(params[:message])
render plain: "Logged!"
endThe Attack:
User model.gid://my-app/User/1 (assuming ID 1 is admin).message=gid://my-app/User/1.LogEventJob is enqueued into Redis/Sidekiq. The payload in Redis is just the string.gid://... and calls GlobalID::Locator.locate.User model with ID 1 is instantiated from the database.Now, instead of the job receiving the string "gid://...", it receives the actual User object.
If LogEventJob performs any method calls on the argument, like message.to_s or message.length, it might be fine. But if the job logic does something like:
def perform(event)
# If event is a User object, this might expose sensitive data
logger.info "Processing event: #{event.attributes}"
endThe logs now contain the admin's password hash, email, and API keys. Or worse, if the job calls a method that exists on both the expected string and the injected object but has side effects on the object (like delete or update), you have a serious logic bomb.
It is important to temper the panic here. This is Object Injection, not arbitrary code execution (usually). When the vulnerability triggers, it hydrates a record from the database. It does not necessarily execute code defined in that class unless the job interacts with it.
However, the risks are still significant:
if arg.is_a?(User), you can force code paths that were intended only for authenticated system processes.after_find or after_initialize hooks. Just loading the record might trigger audit logs, state changes, or cache invalidations that disrupt the system.It is a "silent killer" vulnerability. You might not see a shell pop, but your data is leaking, and your logic is bending in ways the original developer never anticipated.
The remediation is straightforward: Update Rails. Specifically, ensure your activejob gem is version 4.2.0.beta2 or higher. Given that this is a historical vulnerability from the Rails 4.x era, if you are reading this and still running a vulnerable version, you have much bigger problems than just this CVE.
For researchers and developers, the lesson is clear: Never trust data types based solely on string content. Serialization formats should always include an explicit envelope (like a specific JSON key or a binary header) that differentiates between "this is a string of text" and "this is a serialized object."
If you are stuck maintaining a legacy system (condolences), you can mitigate this by sanitizing inputs before they reach perform_later, ensuring that no user-controlled string starts with gid://. But really, just update the gem. It's been a decade.
CVSS:4.0/AV:N/AC:L/AT:N/PR:N/UI:N/VC:N/VI:H/VA:N/SC:N/SI:N/SA:N/E:U| Product | Affected Versions | Fixed Version |
|---|---|---|
Active Job Ruby on Rails | < 4.2.0.beta2 | 4.2.0.beta2 |
| Attribute | Detail |
|---|---|
| CWE | CWE-502 (Deserialization of Untrusted Data) |
| CVSS v4.0 | 6.6 (Medium) |
| Attack Vector | Network |
| Impact | High Integrity / Object Injection |
| Status | Patched (Historical) |
| Affected Component | Active Job / GlobalID |
The application deserializes untrusted data without sufficiently verifying that the resulting data will be valid.