Learn packet analysis with challenging Wireshark labs (+25 advanced PCAP case-studies) !
Blog·

Decoding Ethernet-in-L2TPv3

Decoding Ethernet-in-L2TPv3 with Scapy and Wireshark

If you're dealing with L2TPv3 encapsulation over UDP (often on port 1701), and inside it are raw Ethernet frames, your tools might struggle to decode them properly out of the box.

This article shows how to:

  1. Extract Ethernet frames from L2TPv3 UDP payloads using a Scapy script
  2. Automatically dissect embedded Ethernet in Wireshark with a custom Lua plugin

⚡️ Why this matters

Out of the box, Wireshark won't decode L2TPv3-encapsulated Ethernet unless you:

  • Manually adjust offsets
  • Or write a dissector

🔍 How we knew it was Ethernet → IP → TCP from a hex dump

In one of the original captures, the hex dump of the L2TPv3 payload looked like this:

be ed 89 f6 f5 65 1a 4f a1 86 46 61 08 00 45 00 …

We decoded this manually by matching it against known protocol headers:

  • Next 2 bytes: 08 00 = EtherType for IPv4
  • Next byte: 45 = IP version 4 + header length 5
    • This means: IP header is 20 bytes long, and it is IPv4

🔍 1. Extracting L2TPv3-Encapsulated Ethernet with Scapy

L2TPv3 headers can vary (e.g. optional cookies), so fixed offsets break easily. This script autodetects where the Ethernet frame begins by scanning for a valid EtherType.

✅ Features

  • Detects 0x0800 (IPv4), 0x86DD (IPv6), 0x8100 (802.1Q VLAN)
  • Searches up to 32 bytes into the UDP payload
  • Can be run from the command line

📜 extract_l2tp_eth.py

from scapy.all import *
import struct
import argparse

VALID_ETHERTYPES = [0x0800, 0x86DD, 0x8100]

def looks_like_ethernet(data):
    if len(data) < 14:
        return False
    ethertype = struct.unpack("!H", data[12:14])[0]
    return ethertype in VALID_ETHERTYPES

def find_ethernet_start(payload, max_offset=32):
    for offset in range(max_offset):
        if looks_like_ethernet(payload[offset:offset+14]):
            return offset
    return None

def main():
    parser = argparse.ArgumentParser(description="Extract Ethernet from L2TPv3")
    parser.add_argument("-r", "--read", required=True, help="Input pcap file")
    parser.add_argument("-w", "--write", required=True, help="Output pcap file")
    args = parser.parse_args()

    packets_out = []
    packets = rdpcap(args.read)

    for pkt in packets:
        if UDP in pkt and pkt[UDP].dport == 1701 and Raw in pkt:
            udp_payload = bytes(pkt[Raw])
            offset = find_ethernet_start(udp_payload)
            if offset is not None:
                eth_data = udp_payload[offset:]
                try:
                    eth = Ether(eth_data)
                    packets_out.append(eth)
                except Exception as e:
                    print(f"⚠️ Could not parse Ethernet at offset {offset}: {e}")

    wrpcap(args.write, packets_out)
    print(f"✅ Extracted {len(packets_out)} valid Ethernet frames to {args.write}")

if __name__ == "__main__":
    main()

💻 Usage:

python extract_l2tp_eth.py -r input.pcap -w output_eth.pcap

Then open output_eth.pcap in Wireshark to view clean Ethernet/IP/TCP layers.

🧩 2. Wireshark Lua Plugin for On-the-Fly Decoding

If you want to inspect L2TPv3 traffic live in Wireshark, use this Lua dissector plugin that: • Parses the L2TPv3 header • Scans the payload for valid embedded Ethernet • Passes it to Wireshark’s built-in Ethernet dissector

📜 l2tpv3_eth_autodetect.lua

do
    local l2tpv3 = Proto("l2tpv3", "L2TPv3 Ethernet Tunnel (Autodetect)")

    local f_flags     = ProtoField.uint16("l2tpv3.flags", "Flags", base.HEX)
    local f_reserved  = ProtoField.uint16("l2tpv3.reserved", "Reserved", base.HEX)
    local f_sessionid = ProtoField.uint32("l2tpv3.sessionid", "Session ID", base.HEX)

    l2tpv3.fields = { f_flags, f_reserved, f_sessionid }

    local eth_dissector = Dissector.get("eth_withoutfcs")
    local udp_table = DissectorTable.get("udp.port")

    local function find_eth_offset(buf)
        local max_offset = math.min(64, buf:len() - 14)
        for offset = 0, max_offset do
            local etype_buf = buf(offset + 12, 2)
            if etype_buf then
                local etype = etype_buf:uint()
                if etype == 0x0800 or etype == 0x86DD or etype == 0x8100 then
                    return offset
                end
            end
        end
        return nil
    end

    function l2tpv3.dissector(buf, pinfo, tree)
        if buf:len() < 12 then return end
        pinfo.cols.protocol = "L2TPv3"

        local l2tp_tree = tree:add(l2tpv3, buf(), "L2TPv3 Ethernet Tunnel")
        l2tp_tree:add(f_flags,     buf(0, 2))
        l2tp_tree:add(f_reserved,  buf(2, 2))
        l2tp_tree:add(f_sessionid, buf(4, 4))

        local offset = find_eth_offset(buf)
        if offset then
            local eth_data = buf(offset):tvb()
            eth_dissector:call(eth_data, pinfo, l2tp_tree)
        else
            l2tp_tree:add_expert_info(PI_MALFORMED, PI_WARN, "No valid Ethernet offset found")
        end
    end

    udp_table:add(1701, l2tpv3)
end

🔧 Installation (Wireshark)

1.  Save as l2tpv3_eth_autodetect.lua
2.  Copy to:
•   Linux/macOS: ~/.config/wireshark/plugins/
•   Windows: %APPDATA%\Wireshark\plugins\
3.  Restart Wireshark

✅ Results

Now when you open a .pcap file or live capture with L2TPv3 over UDP/1701, you’ll see:

  • L2TPv3 Ethernet Tunnel Flags: ... Session ID: ...
  • Ethernet II Src: ... Dst: ... Type: IPv4 (0x0800)

This eliminates manual offset decoding or incorrect Data fields, and lets Wireshark decode the full stack: L2TP → Ethernet → IP → TCP/UDP.

🧠 Wrap-Up

With a few lines of Lua and Python, you can go from raw UDP captures to fully decoded Ethernet layers, automatically and reliably. This is especially helpful when analyzing: • L2TPv3 pseudo-wire setups • VPN transport tunnels • Embedded infrastructure traffic