Second bite on GitLab, and some interesting Ruby functions/features

Brief

It’s been a while since I wrote something in this blog last time. I just posted something about several bugs I found several months ago in GitHub Pages and GitHub Enterprise. Go check them out if you are interested in some more examples of incorrect path sanitizing and symbolic links in real world softwares!

But today I’m not going to talk about more of those. I’m going introduce some recent findings about Ruby and how did I a find an RCE bug in GitLab. (CVE-2018-18649)

Ruby and GitLab

I wrote Ruby a lot when I was still in university. I used Ruby almost everywhere I could use and even once submitted an assignment of my Operating System course about threads and processes. However, after graduation I didn’t have much time to continue on the cool stuff with Ruby and focused on something else. :(

Recently, the experiences during the code auditing of GitLab, GitHub and several other cool Ruby projects recalls me the best days of learning Ruby and feels me like I’m designing the software and refining the code snippets with the developers.

As many people say, Ruby is an extremely flexible language and this flexibility brings us elegant implementations, the freedom of creating DSL and etc. However the flexibility also brings unnoticeable bugs. Here I’m going to show some code snippets existed in GitLab.

Duck Typing

If it walks like a duck, swims like a duck, and quacks like a duck, then it probably is a duck.

GitLab uses Grape to create their APIs, for example, the following API is used to upload attachments to the wiki of a project.

        params do
          requires :file, type: File, desc: 'The attachment file to be uploaded'
          optional :branch, type: String, desc: 'The name of the branch'
        end
        post ":id/wikis/attachments", requirements: API::PROJECT_ENDPOINT_REQUIREMENTS do
          authorize! :create_wiki, user_project

          result = ::Wikis::CreateAttachmentService.new(user_project,
                                                        current_user,
                                                        commit_params(declared_params(include_missing: false))).execute
        ...
        end

Normally, Rack intercepts file uploading requests and sets params[key][:tempfile] and params[key][:filename] to the temporary path of the uploaded file and the original filename. So GitLab reads the content of the temp file and saves it afterwards.

        def commit_params(attrs)
          {
            file_name: attrs[:file][:filename],
            file_content: File.read(attrs[:file][:tempfile]),
            branch_name: attrs[:branch]
          }
        end

At the same time, grape has a simple validation for type: File:

        def value_coerced?(value)
          # Rack::Request creates a Hash with filename,
          # content type and an IO object. Do a bit of basic
          # duck-typing.
          value.is_a?(::Hash) && value.key?(:tempfile)
        end

Well, it’s not really duck-typing here. But the comments of the developer reminds me this and it’s surely thinking about duck-typing when it was implementing this function.

Unfortunately, only duck quacking is not enough. A key named :tempfile exists in the params[:file] hash doesn’t really mean params[:file] is really a uploaded file as expected.

Sending a simple POST request, we can easily smuggle our duck-like bird to pass this duck typing check by setting file[tempfile] in the form data.

curl -d "file[tempfile]=/etc/passwd&file[filename]=123" http://host:port

So, our bird can pass the inspection and tell us anything within a GitLab instance.

2018-12-12-CVE-2018-18649-Gitlab-RCE_1

Inheritance

Inheritance is not a Ruby only feature, inheritance exists in almost every OOP language. It is a very helpful for designing softwares. However it also brings some security risks.

In Ruby, there’s a function Kernel#open

open(path [, mode [, perm]] [, opt]) → io or nil
...
If path starts with a pipe character ("|"), a subprocess is created, connected to the caller by a pair of pipes. The returned IO object may be used to write to the standard input and read from the standard output of this subprocess.

Which means, this function could be used to spawn sub-processes as long as the first char of path is the pipe symbol.

irb(main):001:0> Kernel.open('|id').read
=> "uid=0(root) gid=0(root) groups=0(root)\n"
irb(main):002:0> open('|id').read
=> "uid=0(root) gid=0(root) groups=0(root)\n"

And, there are also some other functions having similar behavior, IO::read for example:

read(name, [length [, offset]] [, opt] ) → string
Opens the file, optionally seeks to the given offset, then returns length bytes (defaulting to the rest of the file). read ensures the file is closed before returning.

If name starts with a pipe character ("|"), a subprocess is created in the same way as Kernel#open, and its output is returned.

Which means:

irb(main):003:0> path = '|id'
=> "|id"
irb(main):004:0> IO.read path
=> "uid=0(root) gid=0(root) groups=0(root)\n"

Well, so what’s the business with inheritance here? A very very important point of inheritance is that, the public methods of a parent class is inherited by a child class, we can directly use those inherited methods in child classes as long as we didn’t override them. In Ruby, File is a child class of IO

irb(main):005:0> File.ancestors
=> [File, IO, File::Constants, Enumerable, Object, Kernel, BasicObject]

So,

irb(main):006:0> path = '|id'
=> "|id"
irb(main):007:0> File.read path
(irb):7: warning: IO.read called on File to invoke external command
=> "uid=0(root) gid=0(root) groups=0(root)\n"

easy huh? Till now, the arbitrary file read bug can be directly used to execute system commands remotely.

2018-12-12-CVE-2018-18649-Gitlab-RCE_2

Some thoughts

I was quite shocked when I first discovered this File.read surprise. No matter how to explain, I still can’t accept the fact that calling File.read method on a | started string spawns a sub-process instead of opening a file with that name.

The fact is, not only method read:

pry(main)> (File.methods - File.methods(false)) & IO.methods(false)
=> [:console,
 :read,
 :sysopen,
 :for_fd,
 :popen,
 :foreach,
 :binread,
 :new,
 :binwrite,
 :write,
 :copy_stream,
 :select,
 :pipe,
 :open,
 :try_convert,
 :readlines]

All of these functions are inherited by File directly from IO, and some of them have similar behavior with File.read.

According to the warning message printed in irb, I believe that Ruby-lang developers already know this and are trying to rise notice of the users. It’s a good signal but I think it might be better to totally avoid this kind of unexpected behavior from the language implementation layer, such as override them in File.

Timeline

GitLab’s bug bounty program is very responsive and regularly updated to keep reporters in the loop.

  • Oct 24th, Reported to GitLab
  • Oct 29th, Patch released https://about.gitlab.com/2018/10/29/security-release-gitlab-11-dot-4-dot-3-released/
  • Oct 29th, $10000 bounty was rewarded for this report

Categories:

Updated: