Running commands from RDoc by starting a filename with a pipe
- Identifier
- CVE-2021-31799
- Software
- RDoc (Ruby)
- Affected
- All releases of RDoc from 3.11 to 6.3.0
- Fixed in
- RDoc 6.3.1 and later. Ruby 3.0.2 (bundles RDoc 6.3.1), Ruby 2.7.4
- Reported by
- Alexandr Savca
- Disclosed
- 02 May 2021
The proof of concept is a filename. This one comes from the regression test that shipped with the fix:
| touch evil.txt && echo tagsPut a file with that name in a Ruby project and run rdoc in the directory. evil.txt appears. No shell escaping to get right, no memory corruption, no clever encoding. The name of the file is the payload.
Alexandr Savca reported this to Ruby through HackerOne. The advisory went up on ruby-lang.org on 2 May 2021 as CVE-2021-31799. RDoc is the documentation generator that ships with Ruby, the thing that turns your comments into HTML and answers ri. It is in the standard library, so if you have Ruby you have RDoc.
Kernel#open has a second job#
Ruby's Kernel#open is the old, convenient way to open a file. open("config.yml") { |f| ... } and you're done. It's also, and this is documented behaviour going back a very long way, a process launcher. From the Ruby documentation:
If
pathstarts with a pipe character ("|"), a subprocess is created, connected to the caller by a pair of pipes.
So open("|ls") doesn't look for a file called |ls. It runs ls and gives you its output as an IO object. Ruby inherited this from Perl, and on its own terms it is a tidy design. The same call reads from a file or from a command, and the code that consumes the IO doesn't care which. The trouble is that the choice between "read this file" and "execute this command" comes from the first character of a string, and in a lot of programs that string came from somewhere else.
Ruby developers mostly know this. It's a standard trick in CTF web challenges. It's also the reason URI.open exists as a separate thing, and the reason open-uri's habit of overriding Kernel#open was deprecated. The question is never whether the behaviour is dangerous. It's how a filename gets into open without anyone thinking of it as user input.
The tags sniff#
Run rdoc with no arguments and it documents the current directory, recursively. It gathers every file it can find and then discards the ones it can't do anything with. That filtering is remove_unparseable, in lib/rdoc/rdoc.rb. Here it is as of 6.3.0, the last vulnerable release:
def remove_unparseable files
files.reject do |file, *|
file =~ /\.(?:class|eps|erb|scpt\.txt|svg|ttf|yml)$/i or
(file =~ /tags$/i and
open(file, 'rb') { |io|
io.read(100) =~ /\A(\f\n[^,]+,\d+$|!_TAG_)/
})
end
endThe first clause is a plain extension blocklist. Compiled classes, fonts, SVGs, YAML: no documentation in those, so drop them by name alone.
The second clause is more careful. It is the reason there's an open here at all. A file whose name ends in tags might be a tags index, the symbol database that ctags and etags generate so your editor can jump to a definition. Those files are usually called tags or TAGS and contain no prose, so RDoc shouldn't try to document them. But tags is also a perfectly good name for a Ruby file, or a directory of templates, or anything else. You can't tell from the name.
So RDoc looks. It reads the first hundred bytes and checks them against \A(\f\n[^,]+,\d+$|!_TAG_). That pattern holds two real file signatures. !_TAG_ is the header line Exuberant Ctags writes at the top of a tags file. The form feed followed by a newline and a name,number line is how Emacs TAGS sections begin. Match either and it's an index, so skip it. That is a considerate piece of code. Somebody thought about a false positive that would produce garbage documentation and wrote a content sniff instead of guessing from the name.
The sniff is why the exploit needs the filename to end in tags. Without that ending the and short-circuits and open is never reached. With it, RDoc opens a filename that came out of a directory listing, using a function that treats a leading | as an instruction rather than a character.
And a leading | is fine in a filename. On any POSIX filesystem the only bytes you can't put in a name are the null byte and /. Pipes, ampersands, spaces, semicolons: all legal, all preserved through a tarball, all checked out by git exactly as recorded.
The fix#
- open(file, 'rb') { |io|
+ File.open(file, 'rb') { |io|File.open opens files. That's all it does. It has no pipe behaviour, no subprocess, no second meaning for a leading character. The commit message says exactly what it's for: "Use File.open to fix the OS Command Injection vulnerability in CVE-2021-31799."
The advisory lists RDoc 3.11 through 6.3.0 as affected. That is nearly a decade of releases. The fix is 6.3.1. Ruby itself picked it up in 3.0.2, 2.7.4 and 2.6.8. The two older branches got backported gem versions, 6.2.1.1 and 6.1.2.1, rather than being moved forward to 6.3.1. If you're on an old Ruby and haven't updated the bundled gem, gem install rdoc gets you 6.3.1 or later.
The exposure is larger than it first looks because of where rdoc runs. It isn't a command you point at something you distrust. It's a command that runs over a source tree you've just checked out, frequently from a Rake task, frequently in CI, frequently as part of building a gem. The attacker doesn't need you to run anything unusual. They need you to clone their repository and generate the docs, which is a thing you might do to read the docs.
The advisory's own description is three sentences and it is the whole bug:
RDoc used to call
Kernel#opento open a local file. If a Ruby project has a file whose name starts with|and ends withtags, the command following the pipe character is executed. A malicious Ruby project could exploit it to run an arbitrary command execution against a user who attempts to runrdoccommand.