-- -- Wireshark dissector for the WireGuard tunnel protocol -- Copyright (C) 2017 Peter Wu -- -- This work is licensed under the terms of GPLv2 (or any later version). -- local proto_wg = Proto.new("wg", "WireGuard") local type_names = { [1] = "Handshake Initiation", [2] = "Handshake Response", [3] = "Cookie Reply", [4] = "Transport Data", } local F = { type = ProtoField.uint8("wg.type", "Type", base.DEC, type_names), reserved = ProtoField.none("wg.reserved", "Reserved"), sender = ProtoField.uint32("wg.sender", "Sender", base.HEX), ephemeral = ProtoField.bytes("wg.ephemeral", "Ephemeral"), static_data = ProtoField.bytes("wg.static_data", "Static"), timestamp_tai64_label = ProtoField.bytes("wg.timestamp_tai64_label", "TAI64 Label"), timestamp_nano = ProtoField.uint32("wg.timestamp_nano", "Nanoseconds"), timestamp_value = ProtoField.absolute_time("wg.timestamp_value", "Timestamp", base.UTC), mac1 = ProtoField.bytes("wg.mac1", "mac1"), mac2 = ProtoField.bytes("wg.mac2", "mac2"), receiver = ProtoField.uint32("wg.receiver", "Receiver", base.HEX), nonce = ProtoField.bytes("wg.nonce", "Nonce"), cookie = ProtoField.bytes("wg.cookie", "Cookie"), counter = ProtoField.uint64("wg.counter", "Counter"), } local function add_aead_field(F, name, label) F[name] = ProtoField.none("wg." .. name, label .. " (encrypted)") -- The "empty" field does not have data, do not bother adding fields for it. if name ~= "empty" then F[name .. "_ciphertext"] = ProtoField.bytes("wg." .. name .. ".ciphertext", "Ciphertext") end F[name .. "_atag"] = ProtoField.bytes("wg." .. name .. ".auth_tag", "Auth Tag") end add_aead_field(F, "static", "Static") add_aead_field(F, "timestamp", "Timestamp") add_aead_field(F, "empty", "Empty") add_aead_field(F, "packet", "Packet") proto_wg.fields = F -- See function load_keys below for the file format. proto_wg.prefs.keylog_file = Pref.string("Keylog file", "", "Path to keylog file as generated by key-extract.py") proto_wg.prefs.dissect_packet = Pref.bool("Dissect transport data", true, "Disable to prevent the IP dissector from dissecting decrypted transport data") local efs = {} efs.error = ProtoExpert.new("wg.expert.error", "Dissection Error", expert.group.MALFORMED, expert.severity.ERROR) efs.bad_packet_length = ProtoExpert.new("wg.expert.bad_packet_length", "Packet length is too small!", expert.group.MALFORMED, expert.severity.ERROR) efs.decryption_error = ProtoExpert.new("wg.expert.decryption_error", "Decryption error", expert.group.DECRYPTION, expert.severity.NOTE) proto_wg.experts = efs local ip_dissector = Dissector.get("ip") -- Length of AEAD authentication tag local AUTH_TAG_LENGTH = 16 -- Convenience function for consuming part of the buffer and remembering the -- offset for the next time. function next_tvb(tvb) local offset = 0 return setmetatable({ -- Returns the current offset. offset = function() return offset end, -- Returns the TVB with the requested length without advancing offset peek = function(self, len) local t = tvb(offset, len) self.tvb = t return t end, }, { -- Returns the TVB with the requested length __call = function(self, len) local t = tvb(offset, len) offset = offset + len self.tvb = t return t end, }) end -- Gets the bytes within a TvbRange local function tvb_bytes(tvbrange) return tvbrange:raw(tvbrange:offset(), tvbrange:len()) end -- Converts a TAI64 label to the seconds since the Unix epoch. -- See https://cr.yp.to/libtai/tai64.html local function tai64_to_unix(label) local pow2_62 = UInt64(1):lshift(62) if label < pow2_62 then -- TODO this cannot be represented return 0 else -- TODO this can result in loss of precision return (label - pow2_62):tonumber() end end -- -- Decryption helpers (glue) -- local function base64_decode(text) return ByteArray.new(text, true):base64_decode():raw() end local gcrypt do local ok, res = pcall(require, "luagcrypt") if ok then if res.CIPHER_MODE_POLY1305 then gcrypt = res else report_failure("wg.lua: Libgcrypt 1.7 or newer is required for decryption") end else report_failure("wg.lua: cannot load Luagcrypt, decryption is unavailable.\n" .. res) end end -- -- Decryption helpers (independent of Wireshark) -- KEY_STATIC = "STAT" KEY_TIMESTAMP = "TIME" KEY_EMPTY = "EMPT" KEY_TRAFFIC = "DATA" -- Update "keylog" with keys read from "filename". -- On error, the error message is returned. local function load_keys(keylog, filename) local f, err = io.open(filename) if not f then -- Opening the keylog file failed, return the error return "Cannot load keylog file: " .. err end -- Populate subtables. for k, v in ipairs({KEY_STATIC, KEY_TIMESTAMP, KEY_EMPTY, KEY_TRAFFIC}) do if not keylog[v] then keylog[v] = {} end end -- Read lines of the format: -- " " for handshake secrets or -- " " for traffic secrets where: -- -- is one of the key types ("STAT", "TIME", "EMPTY"), -- and are 32-bit IDs (e.g. 0x12345678), -- is the base64-encoded symmetric key (32 bytes), -- is the base64-encoded additional data (32 bytes). -- Unrecognized lines (empty lines and lines starting with "#") are ignored. while true do local line = f:read() if not line then break end -- break on EOF -- First try to find traffic secrets, else try handshake secrets. local what = KEY_TRAFFIC local peer_id, key = string.match(line, "^(0x%x+) ([%w/+=]+)$") local aad if not key then what, peer_id, key, aad = string.match(line, "^(%u+) (0x%x+) ([%w/+=]+) ([%w/+=]+)$") end if what and keylog[what] then peer_id = tonumber(peer_id) key = base64_decode(key) if aad then aad = base64_decode(aad) end keylog[what][peer_id] = {key, aad} end end f:close() end -- Try to load a key for the given sender, returning the key and additional -- authenticated data (possibly nil if there are none) or two nils followed by -- an error message (if an IO error occured). local function load_key(keylog, filename, key_type, peer_id) local keylog_sub = keylog[key_type] if not keylog_sub then keylog_sub = {} keylog[key_type] = keylog_sub end if not keylog_sub[peer_id] then -- Key ID is not yet known, try to load from file. local err = load_keys(keylog, filename) if err then return nil, nil, err end end local result = keylog_sub[peer_id] if result then return table.unpack(result) end end local function decrypt_aead_gcrypt(key, counter, encrypted, aad) local cipher = gcrypt.Cipher(gcrypt.CIPHER_CHACHA20, gcrypt.CIPHER_MODE_POLY1305) cipher:setkey(key) local nonce if counter == 0 then nonce = string.rep("\0", 12) else -- UInt64 type was passed in nonce = Struct.pack(" 0 then subtree:add(F[fieldname .. "_ciphertext"], t(datalen)) encr_tvb = t.tvb end subtree:add(F[fieldname .. "_atag"], t(AUTH_TAG_LENGTH)) atag_tvb = t.tvb -- Try to decrypt and authenticate if possible. if gcrypt then local key, aad, err, keylog_file keylog_file = proto_wg.prefs.keylog_file while keylog_file and keylog_file ~= "" do -- Try to load key key, aad, err = load_key(keylog_cache, keylog_file, key_type, peer_id) if not key then err = err or "Cannot find key in keylog file" break end -- Decrypt and authenticate the buffer local encr_data = encr_tvb and tvb_bytes(encr_tvb) or "" local decrypted decrypted, err = decrypt_aead(key, counter, encr_data, tvb_bytes(atag_tvb), aad) -- Skip further processing if authentication tag failed if not decrypted or err then break end -- Decryption success, add the decrypted contents and return tvb if decrypted ~= "" then decr_tvb = ByteArray.new(decrypted, true) :tvb("Decrypted " .. fieldname) end subtree:add("(authentication tag verified)") break end -- If any decryption error occurred, show it. if err then subtree:add_proto_expert_info(efs.decryption_error, err) end end return subtree, decr_tvb end function dissect_initiator(tvb, pinfo, tree) local t = next_tvb(tvb) local subtree, subtvb tree:add(F.type, t(1)) tree:add(F.reserved, t(3)) tree:add_le(F.sender, t(4)) local sender_id = t.tvb:le_uint() pinfo.cols.info:append(string.format(", sender=0x%08X", sender_id)) tree:add(F.ephemeral, t(32)) subtree, subtvb = dissect_aead(t, tree, 32, "static", 0, KEY_STATIC, sender_id) if subtvb then tree:add(F.static_data, subtvb()) end subtree, subtvb = dissect_aead(t, tree, 12, "timestamp", 0, KEY_TIMESTAMP, sender_id) if subtvb then local time_t = next_tvb(subtvb) subtree:add(F.timestamp_tai64_label, time_t(8)) local sec = tai64_to_unix(time_t.tvb:uint64()) subtree:add(F.timestamp_nano, time_t(4)) local ns = time_t.tvb:uint() local time = NSTime.new(sec, ns) tree:add(F.timestamp_value, subtvb(), time) end tree:add(F.mac1, t(16)) tree:add(F.mac2, t(16)) return t:offset() end function dissect_responder(tvb, pinfo, tree) local t = next_tvb(tvb) local subtree, subtvb tree:add(F.type, t(1)) tree:add(F.reserved, t(3)) tree:add_le(F.sender, t(4)) local sender_id = t.tvb:le_uint() pinfo.cols.info:append(string.format(", sender=0x%08X", sender_id)) tree:add_le(F.receiver, t(4)) pinfo.cols.info:append(string.format(", receiver=0x%08X", t.tvb:le_uint())) tree:add(F.ephemeral, t(32)) dissect_aead(t, tree, 0, "empty", 0, KEY_EMPTY, sender_id) tree:add(F.mac1, t(16)) tree:add(F.mac2, t(16)) return t:offset() end function dissect_cookie(tvb, pinfo, tree) local t = next_tvb(tvb) tree:add(F.type, t(1)) tree:add(F.reserved, t(3)) tree:add_le(F.receiver, t(4)) pinfo.cols.info:append(string.format(", receiver=0x%08X", t.tvb:le_uint())) tree:add(F.nonce, t(24)) -- TODO handle cookie (need to update key-probe.sh/key-extract.sh too) dissect_aead(t, tree, 16, "cookie") return t:offset() end function dissect_data(tvb, pinfo, tree) local t = next_tvb(tvb) local subtree, subtvb tree:add(F.type, t(1)) tree:add(F.reserved, t(3)) tree:add_le(F.receiver, t(4)) local receiver_id = t.tvb:le_uint() pinfo.cols.info:append(string.format(", receiver=0x%08X", receiver_id)) tree:add_le(F.counter, t(8)) local counter = t.tvb:le_uint64() pinfo.cols.info:append(string.format(", counter=%s", counter)) local packet_length = tvb:len() - t:offset() if packet_length < AUTH_TAG_LENGTH then -- Should not happen, it is a malformed packet. tree:add_tvb_expert_info(efs.bad_packet_length. t(packet_length)) return t:offset() end local datalen = packet_length - AUTH_TAG_LENGTH if datalen > 0 then pinfo.cols.info:append(string.format(", datalen=%s", datalen)) else pinfo.cols.info:append(", Keep-Alive") end subtree, subtvb = dissect_aead(t, tree, datalen, "packet", counter, KEY_TRAFFIC, receiver_id) return t:offset(), subtvb end local types = { [1] = dissect_initiator, [2] = dissect_responder, [3] = dissect_cookie, [4] = dissect_data, } function proto_wg.dissector(tvb, pinfo, tree) if tvb:len() < 4 then return 0 end local type_val = tvb(0,1):uint() -- "Reserved" must be zero at the moment if tvb(1,3):uint() ~= 0 then return 0 end local subdissector = types[type_val] if not subdissector then return 0 end pinfo.cols.protocol = "WireGuard" pinfo.cols.info = type_names[type_val] local subtree = tree:add(proto_wg, tvb()) local success, ret, next_tvb = pcall(subdissector, tvb, pinfo, subtree) if success then if next_tvb and not proto_wg.prefs.dissect_packet then subtree:add("(IP packet not shown, preference \"Dissect transport data\" is disabled)") elseif next_tvb then local err success, err = pcall(ip_dissector, next_tvb, pinfo, tree) if not success then subtree:add_proto_expert_info(efs.error, err) end end return ret else -- An error has occurred... Do not propagate it since Wireshark would -- then try a different heuristics dissectors. subtree:add_proto_expert_info(efs.error, ret) return tvb:len() end end proto_wg:register_heuristic("udp", proto_wg.dissector)