local nmap = require('nmap')
local shortport = require('shortport')
local sslcert = require('sslcert')
local stdnse = require('stdnse')
local vulns = require('vulns')
local tls = require 'tls'
local tableaux = require "tableaux"

description = [[
Detects whether a server is vulnerable to the SSL/TLS "CCS Injection"
vulnerability (CVE-2014-0224), first discovered by Masashi Kikuchi.
The script is based on the ccsinjection.c code authored by Ramon de C Valle
(https://gist.github.com/rcvalle/71f4b027d61a78c42607)

In order to exploit the vulnerablity, a MITM attacker would effectively
do the following:

    o Wait for a new TLS connection, followed by the ClientHello
      ServerHello handshake messages.

    o Issue a CCS packet in both the directions, which causes the OpenSSL
      code to use a zero length pre master secret key. The packet is sent
      to both ends of the connection. Session Keys are derived using a
      zero length pre master secret key, and future session keys also
      share this weakness.

    o Renegotiate the handshake parameters.

    o The attacker is now able to decrypt or even modify the packets
      in transit.

The script works by sending a 'ChangeCipherSpec' message out of order and
checking whether the server returns an 'UNEXPECTED_MESSAGE' alert record
or not. Since a non-patched server would simply accept this message, the
CCS packet is sent twice, in order to force an alert from the server. If
the alert type is different than 'UNEXPECTED_MESSAGE', we can conclude
the server is vulnerable.
]]

---
-- @usage
-- nmap -p 443 --script ssl-ccs-injection <target>
--
-- @output
-- PORT    STATE SERVICE
-- 443/tcp open  https
-- | ssl-ccs-injection:
-- |   VULNERABLE:
-- |   SSL/TLS MITM vulnerability (CCS Injection)
-- |     State: VULNERABLE
-- |     Risk factor: High
-- |     Description:
-- |       OpenSSL before 0.9.8za, 1.0.0 before 1.0.0m, and 1.0.1 before
-- |       1.0.1h does not properly restrict processing of ChangeCipherSpec
-- |       messages, which allows man-in-the-middle attackers to trigger use
-- |       of a zero-length master key in certain OpenSSL-to-OpenSSL
-- |       communications, and consequently hijack sessions or obtain
-- |       sensitive information, via a crafted TLS handshake, aka the
-- |       "CCS Injection" vulnerability.
-- |
-- |     References:
-- |       https://cve.mitre.org/cgi-bin/cvename.cgi?name=CVE-2014-0224
-- |       http://www.cvedetails.com/cve/2014-0224
-- |_      http://www.openssl.org/news/secadv_20140605.txt

author = "Claudiu Perta <claudiu.perta@gmail.com>"
license = "Same as Nmap--See https://nmap.org/book/man-legal.html"
categories = { "vuln", "safe" }
dependencies = {"https-redirect"}

portrule = function(host, port)
  return port.protocol == "tcp" and (shortport.ssl(host, port) or sslcert.getPrepareTLSWithoutReconnect(port))
end

local Error = {
  NOT_VULNERABLE    = 0,
  CONNECT           = 1,
  PROTOCOL_MISMATCH = 2,
  SSL_HANDSHAKE     = 3,
  TIMEOUT           = 4
}

---
-- Reads an SSL/TLS record and returns true if it's any fatal
-- alert and false otherwise.
local function fatal_alert(s)
  local status, buffer = tls.record_buffer(s)
  if not status then
    return false
  end

  local position, record = tls.record_read(buffer, 1)
  if record == nil then
    return false
  end

  if record.type ~= "alert" then
    return false
  end

  for _, body in ipairs(record.body) do
    if body.level == "fatal" then
      return true
    end
  end

  return false
end

---
-- Reads an SSL/TLS record and returns true if it's a fatal,
-- 'unexpected_message' alert and false otherwise.
local function alert_unexpected_message(s)
  local status, buffer
  status, buffer = tls.record_buffer(s, buffer, 1)
  if not status then
    return false
  end

  local position, record = tls.record_read(buffer, 1)
  if record == nil then
    return false
  end

  if record.type ~= "alert" then
    -- Mark this as VULNERABLE, we expect an alert record
    return true,true
  end

  for _, body in ipairs(record.body) do
    if body.level == "fatal" and body.description == "unexpected_message" then
      return true,false
    end
  end

  return true,true
end

local function test_ccs_injection(host, port, version)
  local hello = tls.client_hello({
      ["protocol"] = version,
      -- Only negotiate SSLv3 on its own;
      -- TLS implementations may refuse to answer if SSLv3 is mentioned.
      ["record_protocol"] = (version == "SSLv3") and "SSLv3" or "TLSv1.0",
      -- Claim to support every cipher
      -- Doesn't work with IIS, but IIS isn't vulnerable
      ["ciphers"] = tableaux.keys(tls.CIPHERS),
      ["compressors"] = {"NULL"},
      ["extensions"] = {
        -- Claim to support common elliptic curves
        ["elliptic_curves"] = tls.EXTENSION_HELPERS["elliptic_curves"](
          tls.DEFAULT_ELLIPTIC_CURVES),
      },
    })

  local status, err
  local s
  local specialized = sslcert.getPrepareTLSWithoutReconnect(port)
  if specialized then
    status, s = specialized(host, port)
    if not status then
      stdnse.debug3("Connection to server failed: %s", s)
      return false, Error.CONNECT
    end
  else
    s = nmap.new_socket()
    status, err = s:connect(host, port)
    if not status then
      stdnse.debug3("Connection to server failed: %s", err)
      return false, Error.CONNECT
    end
  end

  -- Set a sufficiently large timeout
  s:set_timeout(10000)

  -- Send Client Hello to the target server
  status, err = s:send(hello)
  if not status then
    stdnse.debug1("Couldn't send Client Hello: %s", err)
    s:close()
    return false, Error.CONNECT
  end

  -- Read response
  local done = false
  local i = 1
  local response
  repeat
    status, response, err = tls.record_buffer(s, response, i)
    if err == "TIMEOUT" or not status then
      stdnse.verbose1("No response from server: %s", err)
      s:close()
      return false, Error.TIMEOUT
    end

    local record
    i, record = tls.record_read(response, i)
    if record == nil then
      stdnse.debug1("Unknown response from server")
      s:close()
      return false, Error.NOT_VULNERABLE
    elseif record.protocol ~= version then
      stdnse.debug1("Protocol version mismatch (%s)", version)
      s:close()
      return false, Error.PROTOCOL_MISMATCH
    elseif record.type == "alert" then
      for _, body in ipairs(record.body) do
        if body.level == "fatal" then
          stdnse.debug1("Fatal alert: %s", body.description)
          -- Could be something else, but this lets us retry
          return false, Error.PROTOCOL_MISMATCH
        end
      end
    end

    if record.type == "handshake" then
      for _, body in ipairs(record.body) do
        if body.type == "server_hello_done" then
          stdnse.debug1("Handshake completed (%s)", version)
          done = true
        end
      end
    end
  until done

  -- Send the change_cipher_spec message twice to
  -- force an alert in the case the server is not
  -- patched.

  -- change_cipher_spec message
  local ccs = tls.record_write(
    "change_cipher_spec", version, "\x01")

  -- Send the first ccs message
  status, err = s:send(ccs)
  if not status then
    stdnse.debug1("Couldn't send first ccs message: %s", err)
    s:close()
    return false, Error.SSL_HANDSHAKE
  end

  -- Optimistically read the first alert message
  -- Shorter timeout: we expect most servers will bail at this point.
  s:set_timeout(stdnse.get_timeout(host))
  -- If we got an alert right away, we can stop right away: it's not vulnerable.
  if fatal_alert(s) then
    s:close()
    return false, Error.NOT_VULNERABLE
  end
  -- Restore our slow timeout
  s:set_timeout(10000)

  -- Send the second ccs message
  status, err = s:send(ccs)
  if not status then
    stdnse.debug1("Couldn't send second ccs message: %s", err)
    s:close()
    return false, Error.SSL_HANDSHAKE
  end

  -- Read the alert message
  local vulnerable
  status,vulnerable = alert_unexpected_message(s)

  -- Leave the target not vulnerable in case of an error. This could occur
  -- when running against a different TLS/SSL implementations (e.g., GnuTLS)
  if not status then
    stdnse.debug1("Couldn't get reply from the server (probably not OpenSSL)")
    s:close()
    return false, Error.SSL_HANDSHAKE
  end

  if not vulnerable then
    stdnse.debug1("Server returned UNEXPECTED_MESSAGE alert, not vulnerable")
    s:close()
    return false, Error.NOT_VULNERABLE
  else
    stdnse.debug1("Vulnerable - alert is not UNEXPECTED_MESSAGE")
    s:close()
    return true
  end
end

action = function(host, port)
  local vuln_table = {
    title = "SSL/TLS MITM vulnerability (CCS Injection)",
    state = vulns.STATE.NOT_VULN,
    risk_factor = "High",
    description = [[
OpenSSL before 0.9.8za, 1.0.0 before 1.0.0m, and 1.0.1 before 1.0.1h
does not properly restrict processing of ChangeCipherSpec messages,
which allows man-in-the-middle attackers to trigger use of a zero
length master key in certain OpenSSL-to-OpenSSL communications, and
consequently hijack sessions or obtain sensitive information, via
a crafted TLS handshake, aka the "CCS Injection" vulnerability.
    ]],
    references = {
      'https://cve.mitre.org/cgi-bin/cvename.cgi?name=CVE-2014-0224',
      'http://www.cvedetails.com/cve/2014-0224',
      'http://www.openssl.org/news/secadv_20140605.txt'
    }
  }

  local report = vulns.Report:new(SCRIPT_NAME, host, port)

  -- client hello will support multiple versions of TLS. We only retry to fall
  -- back to SSLv3, which some implementations won't allow in combination with
  -- newer versions.
  for _, tls_version in ipairs({"TLSv1.2", "SSLv3"}) do
    local vulnerable, err = test_ccs_injection(host, port, tls_version)

    -- Return an explicit message in case of a TIMEOUT,
    -- to avoid considering this as not vulnerable.
    if err == Error.TIMEOUT then
      return "No reply from server (TIMEOUT)"
    end

    if err ~= Error.PROTOCOL_MISMATCH then
      if vulnerable then
        vuln_table.state = vulns.STATE.VULN
      end
      break
    end
  end

  return report:make_output(vuln_table)
end
