Gitlab Project Import RCE Analysis (CVE-2022-2185)
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.
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:
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
Tìm tiếp các dòng có keygitlab: ## Web server settings (note: host is the FQDN, do not include http://) host: 192.168.139.137 port: 3000 https: false
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:
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:# workers 2
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ở foldergdk stop gdk start webpack rails-background-jobs sshd praefect praefect-gitaly-0 redis postgresql
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
Thêm 1 config Rails
như sau:
Và 1 config sidekiq:
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
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,
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:
Đọ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:
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
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
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
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
Stacktrace tới đây như sau:
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)
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:
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:
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:
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:
Stacktrace tới đoạn DecompressedArchiveSizeValidator.execute
Tuy nhiên, theo nhánh này thì ta không thể control được @archive_path
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!
Giá trị này vẫn null tại Gitlab::ImportExport::FileImporter.new
Chỉ tới khi gọi tới Gitlab::ImportExport::FileImporter.copy_archive thì giá trị này mới được set:
@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 ¯_(ツ)_/¯
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
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
Sau khi điền đúng ta sẽ vào được trang import như sau, click bừa vào import 1 cái:
Để ý trong Burpsuite, ta có một request như sau:
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:
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
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:
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:
Ví dụ như với project_entity
, ta có một số Pipeline như sau:
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
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
Nội dung của ProjectPipeline:
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
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:
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:
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:
Với ProjectAttributesTransformer.execute:
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)
- 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 (〜 ̄▽ ̄)〜
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:
Trong description, gitlab cũng có đề cập tới special elements
này có thể gây ra command injection
Hiện tại, data
sau khi extract và transform sẽ được truyền vào Projects::CreateService.execute
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'
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:
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:
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:
Như đã nói qua ở phần 1, GitlabProjectsImportService.execute sau đó sẽ gọi tới prepare_import_params
để xử lý các params:
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.
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)
Nội dung của wait_for_archived_file:
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:
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ụ:
Đế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!