Arbitrary shell execution when extracting or listing files contained in a malicious rpm.
Description
Arr-pm Ruby library prior to 0.0.12 is vulnerable to OS command injection via a malicious RPM payload compressor field, leading to arbitrary shell execution.
AI Insight
LLM-synthesized narrative grounded in this CVE's description and references.
Arr-pm Ruby library prior to 0.0.12 is vulnerable to OS command injection via a malicious RPM payload compressor field, leading to arbitrary shell execution.
Vulnerability
Details CVE-2022-39224 is an OS command injection vulnerability in the Ruby arr-pm library, affecting versions before 0.0.12. The flaw exists in the extract and files methods of the RPM::File class, which unsafely handle the 'payload compressor' field from an RPM file. [1] Instead of validating the field against a whitelist of allowed compressors (gzip, bzip2, xz, zstd, lzma), the library passes it directly to a shell command, enabling injection of arbitrary commands. [1][2]
Exploitation
An attacker can exploit this by crafting a malicious RPM file with a specially crafted 'payload compressor' value containing shell metacharacters. If a system using arr-pm processes such an RPM (e.g., during extraction or file listing), the injected commands execute with the privileges of the process. No authentication is required if the application accepts untrusted RPM files. [1][2]
Impact
Successful exploitation grants an attacker arbitrary shell command execution on the target system, potentially leading to full compromise of the application or server. The vulnerability is particularly dangerous in environments that handle user-uploaded RPM packages. [1]
Mitigation
The issue is fixed in version 0.0.12, which refactors the file listing to avoid shell invocation and instead uses RPM tag data directly. [4] Users should update immediately. As a workaround, ensure that any processed RPMs contain only known payload compressor values (gzip, bzip2, xz, zstd, lzma) by pre-validating with the rpm command-line tool. [1]
AI Insight generated on May 21, 2026. Synthesized from this CVE's description and the cited reference URLs; citations are validated against the source bundle.
Affected packages
Versions sourced from the GitHub Security Advisory.
| Package | Affected versions | Patched versions |
|---|---|---|
arr-pmRubyGems | < 0.0.12 | 0.0.12 |
Affected products
2- Range: < 0.0.12
Patches
32 files changed · +2 −2
arr-pm.gemspec+1 −1 modified@@ -2,7 +2,7 @@ Gem::Specification.new do |spec| files = %x{git ls-files}.split("\n") spec.name = "arr-pm" - spec.version = "0.0.11" + spec.version = "0.0.12" spec.summary = "RPM reader and writer library" spec.description = "This library allows to you to read and write rpm " \ "packages. Written in pure ruby because librpm is not available " \
arr-pm.gemspec+1 −1 modified@@ -2,7 +2,7 @@ Gem::Specification.new do |spec| files = %x{git ls-files}.split("\n") spec.name = "arr-pm" - spec.version = "0.0.11" + spec.version = "0.0.12" spec.summary = "RPM reader and writer library" spec.description = "This library allows to you to read and write rpm " \ "packages. Written in pure ruby because librpm is not available " \
efddd459916eMerge pull request #15 from jordansissel/validate-payload-compressor
3 files changed · +52 −2
arr-pm.gemspec+2 −0 modified@@ -19,6 +19,8 @@ Gem::Specification.new do |spec| spec.add_development_dependency "flores", ">0" spec.add_development_dependency "rspec", ">3.0.0" + spec.add_development_dependency "stud", ">=0.0.23" + spec.add_development_dependency "insist", ">=1.0.0" #spec.homepage = "..." end
lib/arr-pm/file.rb+14 −1 modified@@ -88,6 +88,14 @@ def payload return @payload end # def payload + def valid_compressor?(name) + # I scanned rpm's rpmio.c for payload implementation names and found the following. + # sed -rne '/struct FDIO_s \w+ *= *\{/{ n; s/^.*"(\w+)",$/\1/p }' rpmio/rpmio.c + # It's possible this misses some supported rpm payload compressors. + + [ "gzip", "bzip2", "xz", "lzma", "zstd" ].include?(name) + end + # Extract this RPM to a target directory. # # This should have roughly the same effect as: @@ -97,8 +105,13 @@ def extract(target) if !File.directory?(target) raise Errno::ENOENT.new(target) end + + compressor = tags[:payloadcompressor] + if !valid_compressor?(compressor) + raise "Cannot decompress. This RPM uses an invalid compressor '#{compressor}'" + end - extractor = IO.popen("#{tags[:payloadcompressor]} -d | (cd #{target}; cpio -i --quiet --make-directories)", "w") + extractor = IO.popen("#{compressor} -d | (cd #{target}; cpio -i --quiet --make-directories)", "w") buffer = "" begin buffer.force_encoding("BINARY")
spec/rpm/file_spec.rb+36 −1 modified@@ -1,11 +1,14 @@ # encoding: utf-8 require "arr-pm/file" +require "stud/temporary" +require "insist" describe ::RPM::File do + subject { described_class.new(path) } + context "with a known good rpm" do let(:path) { File.join(File.dirname(__FILE__), "../fixtures/pagure-mirror-5.13.2-5.fc35.noarch.rpm") } - subject { described_class.new(path) } context "#files" do let(:files) { [ @@ -19,4 +22,36 @@ end end end + + context "#extract" do + # This RPM should be correctly built, but we will modify the tags at runtime to force an error. + let(:path) { File.join(File.dirname(__FILE__), "../fixtures/example-1.0-1.x86_64.rpm") } + let(:dir) { dir = Stud::Temporary.directory } + + after do + FileUtils.rm_rf(dir) + end + + context "with an invalid payloadcompressor" do + before do + subject.tags[:payloadcompressor] = "some invalid | string" + end + + it "should raise an error" do + insist { subject.extract(dir) }.raises(RuntimeError) + end + end + + [ "gzip", "bzip2", "xz", "lzma", "zstd" ].each do |name| + context "with a '#{name}' payloadcompressor" do + before do + subject.tags[:payloadcompressor] = name + end + + it "should succeed" do + reject { subject.extract(dir) }.raises(RuntimeError) + end + end + end + end end
c608f8603ebfMerge pull request #14 from jordansissel/file-list-without-cpio
8 files changed · +58 −86
arr-pm.gemspec+1 −0 modified@@ -18,6 +18,7 @@ Gem::Specification.new do |spec| spec.email = ["jls@semicomplete.com"] spec.add_development_dependency "flores", ">0" + spec.add_development_dependency "rspec", ">3.0.0" #spec.homepage = "..." end
arr-pm.gemspec+1 −0 modified@@ -18,6 +18,7 @@ Gem::Specification.new do |spec| spec.email = ["jls@semicomplete.com"] spec.add_development_dependency "flores", ">0" + spec.add_development_dependency "rspec", ">3.0.0" #spec.homepage = "..." end
lib/arr-pm/file.rb+6 −43 modified@@ -195,49 +195,12 @@ def config_files # # % rpm2cpio blah.rpm | cpio -it def files - return @files unless @files.nil? - - lister = IO.popen("#{tags[:payloadcompressor]} -d | cpio -it --quiet", "r+") - buffer = "" - begin - buffer.force_encoding("BINARY") - rescue NoMethodError - # Do Nothing - end - payload_fd = payload.clone - output = "" - loop do - data = payload_fd.read(16384, buffer) - break if data.nil? # listerextractor.write(data) - lister.write(data) - - # Read output from the pipe. - begin - output << lister.read_nonblock(16384) - rescue Errno::EAGAIN - # Nothing to read, move on! - end - end - lister.close_write - - # Read remaining output - begin - output << lister.read - rescue Errno::EAGAIN - # Because read_nonblock enables NONBLOCK the 'lister' fd, - # and we may have invoked a read *before* cpio has started - # writing, let's keep retrying this read until we get an EOF - retry - rescue EOFError - # At EOF, hurray! We're done reading. - end - - # Split output by newline and strip leading "." - @files = output.split("\n").collect { |s| s.gsub(/^\./, "") } - return @files - ensure - lister.close unless lister.nil? - payload_fd.close unless payload_fd.nil? + # RPM stores the file metadata split across multiple tags. + # A single path's filename (with no directories) is stored in the "basename" tag. + # The directory a file lives in is stored in the "dirnames" tag + # + # We can join each entry of dirnames and basenames to make the full filename. + return tags[:dirnames].zip(tags[:basenames]).map &File.method(:join) end # def files def mask?(value, mask)
lib/arr-pm/file.rb+6 −43 modified@@ -195,49 +195,12 @@ def config_files # # % rpm2cpio blah.rpm | cpio -it def files - return @files unless @files.nil? - - lister = IO.popen("#{tags[:payloadcompressor]} -d | cpio -it --quiet", "r+") - buffer = "" - begin - buffer.force_encoding("BINARY") - rescue NoMethodError - # Do Nothing - end - payload_fd = payload.clone - output = "" - loop do - data = payload_fd.read(16384, buffer) - break if data.nil? # listerextractor.write(data) - lister.write(data) - - # Read output from the pipe. - begin - output << lister.read_nonblock(16384) - rescue Errno::EAGAIN - # Nothing to read, move on! - end - end - lister.close_write - - # Read remaining output - begin - output << lister.read - rescue Errno::EAGAIN - # Because read_nonblock enables NONBLOCK the 'lister' fd, - # and we may have invoked a read *before* cpio has started - # writing, let's keep retrying this read until we get an EOF - retry - rescue EOFError - # At EOF, hurray! We're done reading. - end - - # Split output by newline and strip leading "." - @files = output.split("\n").collect { |s| s.gsub(/^\./, "") } - return @files - ensure - lister.close unless lister.nil? - payload_fd.close unless payload_fd.nil? + # RPM stores the file metadata split across multiple tags. + # A single path's filename (with no directories) is stored in the "basename" tag. + # The directory a file lives in is stored in the "dirnames" tag + # + # We can join each entry of dirnames and basenames to make the full filename. + return tags[:dirnames].zip(tags[:basenames]).map &File.method(:join) end # def files def mask?(value, mask)
spec/fixtures/pagure-mirror-5.13.2-5.fc35.noarch.rpm+0 −0 addedspec/fixtures/pagure-mirror-5.13.2-5.fc35.noarch.rpm+0 −0 addedspec/rpm/file_spec.rb+22 −0 added@@ -0,0 +1,22 @@ +# encoding: utf-8 + +require "arr-pm/file" + +describe ::RPM::File do + context "with a known good rpm" do + let(:path) { File.join(File.dirname(__FILE__), "../fixtures/pagure-mirror-5.13.2-5.fc35.noarch.rpm") } + subject { described_class.new(path) } + + context "#files" do + let(:files) { [ + "/usr/lib/systemd/system/pagure_mirror.service", + "/usr/share/licenses/pagure-mirror", + "/usr/share/licenses/pagure-mirror/LICENSE" + ]} + + it "should have the correct list of files" do + expect(subject.files).to eq(files) + end + end + end +end
spec/rpm/file_spec.rb+22 −0 added@@ -0,0 +1,22 @@ +# encoding: utf-8 + +require "arr-pm/file" + +describe ::RPM::File do + context "with a known good rpm" do + let(:path) { File.join(File.dirname(__FILE__), "../fixtures/pagure-mirror-5.13.2-5.fc35.noarch.rpm") } + subject { described_class.new(path) } + + context "#files" do + let(:files) { [ + "/usr/lib/systemd/system/pagure_mirror.service", + "/usr/share/licenses/pagure-mirror", + "/usr/share/licenses/pagure-mirror/LICENSE" + ]} + + it "should have the correct list of files" do + expect(subject.files).to eq(files) + end + end + end +end
Vulnerability mechanics
Root cause
"The `extract` and `files` methods pass the untrusted `payloadcompressor` tag value directly into `IO.popen`, enabling OS command injection if the value contains shell metacharacters."
Attack vector
An attacker crafts a malicious RPM whose `payloadcompressor` header field contains shell metacharacters (e.g., `some invalid | string`). When a victim calls `RPM::File#extract` or `RPM::File#files`, the library passes this value unsanitized into `IO.popen`, which spawns a shell and executes the injected command. No authentication is required; the attacker only needs to supply the crafted RPM file to the victim's application.
Affected code
The vulnerable code is in `lib/arr-pm/file.rb` in the `extract` method (line using `IO.popen("#{tags[:payloadcompressor]} -d | ...")`) and the `files` method (line using `IO.popen("#{tags[:payloadcompressor]} -d | cpio -it ...")`). Both methods pass the untrusted `tags[:payloadcompressor]` value directly into a shell command without validation.
What the fix does
Patch [patch_id=1641355] adds a `valid_compressor?` method that checks the `payloadcompressor` value against a whitelist of known compressors (`gzip`, `bzip2`, `xz`, `lzma`, `zstd`). If the value is not in the whitelist, `extract` raises a `RuntimeError` before reaching `IO.popen`. Patch [patch_id=1641353] eliminates the command injection entirely in the `files` method by replacing the `IO.popen`/`cpio` pipeline with a safe join of RPM header tags (`dirnames` and `basenames`), removing the need to invoke any external decompressor.
Preconditions
- inputAttacker must supply a crafted RPM file with a malicious payloadcompressor tag value.
- networkNo special network position required; the victim must process the attacker-controlled RPM.
Generated on May 23, 2026. Inputs: CWE entries + fix-commit diffs from this CVE's patches. Citations validated against bundle.
References
6- github.com/advisories/GHSA-88cv-mj24-8w3qghsaADVISORY
- nvd.nist.gov/vuln/detail/CVE-2022-39224ghsaADVISORY
- github.com/jordansissel/ruby-arr-pm/pull/14ghsax_refsource_MISCWEB
- github.com/jordansissel/ruby-arr-pm/pull/15ghsax_refsource_MISCWEB
- github.com/jordansissel/ruby-arr-pm/security/advisories/GHSA-88cv-mj24-8w3qghsax_refsource_CONFIRMWEB
- github.com/rubysec/ruby-advisory-db/blob/master/gems/arr-pm/CVE-2022-39224.ymlghsaWEB
News mentions
0No linked articles in our index yet.