Gitlab Project Import RCE Analysis (CVE-2022-2185)

·

12 min read

Khoảng đầu tháng này, gitlab có release một bản vá bảo mật cho các phiên bản từ 14->15, khá thú vị là trong advisory có nhắc đến một bug post-auth RCE với CVSS 9.9.

image.png Bug này tồn tại trong tính năng "Project Imports" của gitlab, được tìm ra bởi @vakzz. Thật tình cờ, khi mò mẫm trong profile h1 của tác giả, tôi có thấy vào 4 tháng trước ông cũng tìm ra 1 bug tại tính năng import project:

image.png

Nghĩ tưởng ngon ăn nên tôi đã đâm đầu vào học Rails và debug cái bug này! (ai dè 30k ko hề dễ ăn như vậy ( ͡° ͜ʖ ͡°) )

Lưu ý là bài có thể sẽ khá dài hơn so với thông thường, nếu đọc để giải trí và tìm PoC thì bạn đọc có thể lướt tới phần cuối xem video tạm vậy!

ENVIRONMENT SETUP & DEBUGGING

Phần này cũng khá là cồng kềnh, trắc trở và khó khăn, yêu cầu sự kiên nhẫn tới từ vị trí của Re-Searcher! Ban đầu thì mình có tham khảo bài của một bạn ở Sun* để setup env, tuy nhiên sau khi chạy thì khá là chậm, và rất unreliable nên mình quyết định tự setup vậy! Môi trường mình sử dụng máy ảo Ubuntu Desktop 18.04. Đầu tiên là setup bộ GDK của gitlab lên trước:

apt update
apt install make git -y
curl "https://gitlab.com/gitlab-org/gitlab-development-kit/-/raw/main/support/install" | bash

Chờ đâu đó khoảng 15-30p thì gdk sẽ được setup xong, tiếp theo là check out phiên bản có lỗi của gitlab:

cd gitlab-development-kit/gitlab/
git checkout v15.1.0-ee

Sau khi checkout thì sửa các file sau:

  • config/gitlab.yml, tìm dòng config host của gitlab, sửa thành ip của máy ảo để có thể browse từ bên ngoài vào
    gitlab:
      ## Web server settings (note: host is the FQDN, do not include http://)
      host: 192.168.139.137
      port: 3000
      https: false
    
    Tìm tiếp các dòng có key webpack, set enabled: false:
    webpack:
      dev_server:
        enabled: false
    

Sau khi sửa xong, vào folder gitlab/ gõ lệnh sau để compile các resource của server:

rake gitlab:assets:compile
  • config/puma.rb, tìm dòng khai báo workers, comment lại:
    # workers 2
    
    Khi đã sửa các file config xong xuôi, gõ tiếp các lệnh sau để start các service liên quan:
    gdk stop
    gdk start webpack rails-background-jobs sshd praefect praefect-gitaly-0 redis postgresql
    
    Về phía IDE, mình sử dụng RubyMine (một sản phẩm của Jetbrains). Dùng RubyMine, browse tới và mở folder gitlab, IDE sẽ tự detect và setup các component liên quan. Thêm debug config bằng cách mở Run > Edit Configurations

image.png

Thêm 1 config Rails như sau:

image.png

Và 1 config sidekiq:

image.png

Trong trường hợp sử dụng source code clone từ repo của gitlab về, các config để debug đã có sẵn, không cần phải setup thêm

image.png

Như vậy là đã có thể debug ngon nghẻ rồi, mặc dù sidekiq chạy không ổn định lắm, sẽ có trường hợp debug bị miss mất workers, hiện tại mình vẫn chưa rõ tại sao.

CVE-2022-2185 ANALYSIS

Mình dựa vào một bug report trước đó của @vakzz để phân tích bug này, mặc dù sự liên quan giữa 2 bug là không nhiều lắm, nhưng vẫn khuyên bạn đọc nên đọc qua tại đây.

Relate information

Dựa vào thông tin có được từ chính trang lưu thông tin CVE của gitlab CVE-2022-2185.json, ta biết được bug này là một dạng command injection,

image.png

Mặc dù trong phần references có để lại link tới hackerone report và gitlab issue nhưng hiện tại đều đang để ở private mode:

image.png

Đọc các commit trên gitlab-v15.1.1 thì cũng may mắn một chút là không có nhiều commit lắm, trong đó có một commit đáng chú ý như sau:

image.png

Commit 5d58c705 có tên khá thú vị và liên quan tới bug này security-update-bulk-imports-project-pipeline-15-1

Một số thay đổi đáng chú trong commit này: Tại lib/gitlab/import_export/decompressed_archive_size_validator.rb image.png

Method validate_archive_path check các trường hợp @archive_path là Symlink, không phải là String hoặc không phải là File

     def validate_archive_path
        Gitlab::Utils.check_path_traversal!(@archive_path)

        raise(ServiceError, 'Archive path is not a string') unless @archive_path.is_a?(String)
        raise(ServiceError, 'Archive path is a symlink') if File.lstat(@archive_path).symlink?
        raise(ServiceError, 'Archive path is not a file') unless File.file?(@archive_path)
      end

Chương trình sau khi validate_archive_path sẽ tiếp tục gọi tới Open3.popen3(command, pgroup: true) để chạy command Đoạn khai báo command như sau:

    def command
        "gzip -dc #{@archive_path} | wc -c"
      end

Method này trực tiếp nối chuỗi @archive_path vào command gzip -dc, do đó mình suy đoán bug command injection xảy ra tại vị trí này! (Ruby suck, function mà call như attribute vkl ?!)

class DecompressedArchiveSizeValidator được sử dụng ở 2 vị trí đó là:

  • file_importer.rb
  • file_decompression_service.rb

image.png


Side note về workers trong gitlab

Gitlab hoạt động theo cơ chế là giao diện web chỉ thực hiện xử lý các tác vụ chung chung, khi liên quan tới các tác vụ chính, nặng hơn, nó sử dụng thêm sidekiq với vai trò như là các worker, thực hiện các job từ web controller đẩy qua. Đây cũng là lý do mà khi setup debug phải thêm cả config để debug sidekiq


Case 1:

Với nhánh file_importer.rb, ta bắt đầu từ Import::GitlabProjectsController.create và gọi tới Projects::GitlabProjectsImportService.new(current_user, project_params).execute để tạo job

image.png

Dòng 17, 18, 19 đã bị comment để việc debug được dễ dàng hơn, không hiểu tại sao mà môi trường debug bằng GDK lại bị lỗi, tất cả các file upload lên đều bị báo invalid!! Vấn đề này không xảy ra tại các bản product.

project_params đã bị limit, chỉ cho phép truyền vào các tham số: name, path, namespace_id, file

image.png

Stacktrace tới đây như sau:

image.png

Từ GitlabProjectsImportService.execute tiếp tục gọi tới prepare_import_params để sửa, thêm bớt các tham số quan trọng khác (1)

image.png

Sau đó, GitlabProjectsImportService.execute lại gọi tới Projects::CreateService.execute để tạo Project với params vừa truyền vào của GitlabProjectsController. Tại Projects::CreateService.execute, nếu project đang import không phải là một template, method sẽ tiếp tục khởi tạo object Project với params được truyền vào:

image.png

Project được tạo xong thì method sẽ tiếp tục đi vào nhánh gọi tới validate_import_source_enabled! để validate import_type:

image.png

Trong đó sẽ có 2 nhánh thỏa mãn điều kiện, đầu tiên import_type thuộc một trong các loại sau

INTERNAL_IMPORT_SOURCES = %w[bare_repository gitlab_custom_project_template gitlab_project_migration].freeze

Trường hợp thứ 2, import_type sẽ phải tồn tại trong list Gitlab::CurrentSettings.import_sources:

image.png

Tạo xong object và thực hiện thêm một số bước modify linh tinh, method này sẽ gọi tới Projects::CreateService.import_schedule để thêm schedule cho worker thực hiện việc import:

  def import_schedule
      if @project.errors.empty?
        @project.import_state.schedule if @project.import? && !@project.bare_repository_import? && !@project.gitlab_project_migration?
      else
        fail(error: @project.errors.full_messages.join(', '))
      end
    end

Để có thể được vào schedule import thì project này phải có import_type là 'gitlab_project'.

Sau khi được add vào schedule, worker sẽ nhận job và thực thi như sau:

image.png

Stacktrace tới đoạn DecompressedArchiveSizeValidator.execute

image.png

Tuy nhiên, theo nhánh này thì ta không thể control được @archive_path

image.png

Khi worker thực thi job, @archive_file được lấy từ Project.import_source, tuy nhiên attribute này mặc định không được set, có giá trị null!

image.png

Giá trị này vẫn null tại Gitlab::ImportExport::FileImporter.new

image.png

Chỉ tới khi gọi tới Gitlab::ImportExport::FileImporter.copy_archive thì giá trị này mới được set:

image.png

@archive_file_name được gen dựa trên full_path của Project, giá trị này không thể bị thao túng nên theo nhánh này hiện tại ta không thể có bug command injection được ¯_(ツ)_/¯

image.png

Case 2

Do case thứ nhất, file_importer.rb đã bị phế nên mình chuyển qua nhánh thứ 2 để nghiên cứu, nhánh này là file_decompression_service.rb

image.png

Nhánh này khá là ngoằn ngoèo để có thể tìm được payload đúng để access được.

Đầu tiên bạn phải vào phần import group của gitlab, điền các thông tin như Gitlab URL, access token

image.png

Sau khi điền đúng ta sẽ vào được trang import như sau, click bừa vào import 1 cái:

image.png

Để ý trong Burpsuite, ta có một request như sau:

image.png

Search từ khóa group_entity trong source code, mình phát hiện ngoài group_entity thì còn có cả project_entity:

image.png

Tính năng này mình không tìm được trên web cũng như document nào, rất có thể đây là một tính năng ẩn, đang phát triển của gitlab!

Tính năng Bulk Import này được handle bởi Import::BulkImportsController

image.png

Sau khi thực thi create_bulk_import, method BulkImportsController.execute tiếp tục gọi tới BulkImportWorker.perform_async, nội dung method như sau:

image.png

Chú ý vào phần gọi tới BulkImports::CreatePipelineTrackersService.new(entity).execute!. Method này xem xét các Pipeline nào phù hợp để thực thi với các param vừa truyền vào:

image.png

Ví dụ như với project_entity, ta có một số Pipeline như sau:

image.png


Site note về Pipeline trong Bulk Import

Khái niệm này chỉ giành riêng cho Bulk Import, File thực thi các Pipeline này là lib/bulk_imports/pipeline/runner.rb

image.png

image.png

Các Pipeline sẽ có khai báo, override các method như extract, transform, load, after_run.

Runner sẽ duyệt và thực thi các method này theo thứ tự: extract data, transform data, load data, after_run

Và sẽ thực thi lần lượt các Pipeline theo thứ tự được khai báo trong file stage.rb


Quay trở lại với Bulk Import project, pipeline ProjectPipeline sẽ là pipeline đầu tiên được thực thi

image.png

Nội dung của ProjectPipeline:

image.png

Nội dung của method execute là gọi tới Projects::CreateService.execute với tham số params = data. Như đã đề cập trong side note, data chính là dữ liệu được sửa đổi bởi các Transformer

image.png

Các extractor và transformer của ProjectPipeline là:

extractor ::BulkImports::Common::Extractors::GraphqlExtractor, query: Graphql::GetProjectQuery
transformer ::BulkImports::Common::Transformers::ProhibitedAttributesTransformer
transformer ::BulkImports::Projects::Transformers::ProjectAttributesTransformer

Theo flow của Pipeline:

  • GraphqlExtractor.extract sẽ lấy dữ liệu từ target về thông qua graphql
  • ProhibitedAttributesTransformer, ProjectAttributesTransformer sẽ sửa đổi data vừa lấy về

Với GraphqlExtractor, trong fix commit của gitlab Graphql::GetProjectQuery được sửa như sau:

image.png

Có thể thấy rõ ràng ở đây, số lượng variable cần lấy đã được giảm thiểu đi khá nhiều.

Một ví dụ về data lấy từ GraphqlExtractor:

image.png

Với ProhibitedAttributesTransformer, chức năng chủ yếu của transformer này là loại bỏ một số attribute nhạy cảm:

image.png

Với ProjectAttributesTransformer.execute:

image.png

Method này nhận vào data, thực hiện thêm một số bước set các attribute cần thiết như import_type, name, path, sau đó gọi tới data.transform_keys!(&:to_sym) để thực hiện convert tất cả các key của Hash vừa truyền vào sang dạng Symbol.

//Trong Ruby có khái niệm Symbol vs String, đại khái Symbol sẽ có dấu hai chấm ":" ở phía trước

Đây là một ví dụ sau khi thực hiện data.transform_keys!(&:to_sym)

image.png

  • Và nên nhớ rằng, data vẫn hoàn toàn có thể bị control, do nó lấy về từ GraphQL, GraphQL lại lấy từ trang web mà mình control

Quay trở lại phần import đã nhắc ở Case 1, ta hoàn toàn có thể ghi lại được project.import_source, từ đó có thể control được @archive_file và RCE (〜 ̄▽ ̄)〜

image.png

image.png

Trong fix commit của ProjectAttributesTransformer, thay vì nhận vào data, transformer này đã tạo một Hash mới, chỉ thêm một số key/value cần thiết và return đúng Hash đó, nghĩa là đã hạn chế được những attribute khác bị thêm vào:

image.png

Trong description, gitlab cũng có đề cập tới special elements này có thể gây ra command injection

image.png

Hiện tại, data sau khi extract và transform sẽ được truyền vào Projects::CreateService.execute

image.png

Có một điều rất tiếc là project tạo ra từ ProjectPipeline lại chỉ có thể có import_type = 'gitlab_project_migration'

image.png

Khi check để thêm vào import schedule, project này sẽ bị reject bởi điều kiện !@project.gitlab_project_migration?

  def import_schedule
      if @project.errors.empty?
        @project.import_state.schedule if @project.import? && !@project.bare_repository_import? && !@project.gitlab_project_migration?
      else
        fail(error: @project.errors.full_messages.join(', '))
      end
    end

Mặc dù đã có thể control được các attribute của object Project, nhưng attribute quan trọng nhất đã bị ghi đè mất và không có cách nào để ghi đè lại được (thực ra là có nhưng mình sẽ nói trong bài khác).


Case 1 + 2 = 3

Và mình bị stuck ở đó gần 2 tuần liền, ...

Cũng một phần là do tính năng debug của ruby khá lởm, lúc thì chạm bp, lúc thì không. Đôi khi RubyMine còn tự dưng crash, khá là đau đầu với đám này.

Cho tới vài ngày gần đây, mình có tìm ra một cách khác debug nhanh hơn mà không cần bật gitlab server, đó là dùng chính cái RSpec của gitlab để debug. Cái thuận tiện của việc này là tránh phải chờ sidekiq bị duplicate job và không chạy job mình đang debug!

Ở đây mình debug ProjectPipeline bằng project_pipeline_spec.rb, sửa một số data liên quan tới project_data rồi chạy là xong:

image.png

Nhờ vậy, việc debug của mình khá là nhanh và đã đạt được một số kết quả mới.

Đọc kỹ lại nhánh Projects::CreateService.execute, mình nhận ra là đã bỏ qua mất nhánh xử lý template:

image.png

image.png

Nhánh này gọi tới Projects::CreateFromTemplateService.execute cùng với params chính là data lấy từ ProjectPipeline.

Method này chủ yếu check sự tồn tại của template_name, sau đó gọi tới GitlabProjectsImportService.execute cùng với params để xử lý tiếp:

image.png

Như đã nói qua ở phần 1, GitlabProjectsImportService.execute sau đó sẽ gọi tới prepare_import_params để xử lý các params:

image.png

Tại đây, nếu đang xử lý template_file, chương trình sẽ ghi đè lại param import_type = 'gitlab_project'.

Sau khi xử lý param xong, GitlabProjectsImportService.execute sẽ gọi tiếp tới Projects::CreateService.execute để tạo lại project với các params vừa sửa.

image.png

Như vậy, import_type đã được sửa thành 'gitlab_project', mà chương trình vẫn sử dụng lại các params cũ của Pipeline => RCE ( ͡° ͜ʖ ͡°)( ͡° ͜ʖ ͡°)( ͡° ͜ʖ ͡°)

Có một điểm cần lưu ý của bug này đó là command sẽ không được thực thi luôn.

Tại Gitlab::ImportExport::FileImporter.import, method wait_for_archived_file sẽ được gọi để chờ @archive_file tồn tại rồi mới đi vào nhánh xử lý phía dưới (nhánh có thể inject command)

image.png

Nội dung của wait_for_archived_file:

image.png

Với MAX_RETRIES = 8, đoạn này chương trình sẽ loop 8 lần để chờ file exists, với mỗi lần sẽ sleep 2**i, áp dụng công thức tính tổng chuỗi lũy thừa vừa hỏi được của mấy em năm nhất, ta biết được sẽ phải chờ 2**8 -1 = 255 giây nếu file không tồn tại:

image.png

Và với trường hợp file không tồn tại thì method này cũng vẫn tiếp tục gọi tới yield ở phía dưới, đồng nghĩa với việc các statement sau wait_for_archived_file vẫn được gọi bình thường, ví dụ:

image.png

Đến đây thì mọi chuyện về bug này đã sáng tỏ rồi, mặc dù quá trình đọc Ruby/Rails khá là gian nan và đau khổ, nhưng cũng đem lại được nhiều kiến thức và vài thứ hay ho, hy vọng sẽ có đủ để có thể chia sẻ cho bạn đọc trong một ngày không xa!

PoC video:

Đang chờ 255s để trigger bug nên chưa có video!

youtu.be/mLotC1oxNm8