diff options
| author | srdusr <[email protected]> | 2024-05-21 22:24:00 +0200 |
|---|---|---|
| committer | srdusr <[email protected]> | 2024-05-21 22:24:00 +0200 |
| commit | fbedc55d5aa861c381701c9f913b34ee7ab57ec4 (patch) | |
| tree | d34c3648b61d417f2a5d8690e0eb4a76bd64c943 | |
| parent | e0f4c701028aa81026a17cf9ebfb36112184f4bc (diff) | |
| download | packeteer-fbedc55d5aa861c381701c9f913b34ee7ab57ec4.tar.gz packeteer-fbedc55d5aa861c381701c9f913b34ee7ab57ec4.zip | |
Add GUI parity for -c/-a, mDNS/SSH dissectors, and two new fuzz harnesses
GUI parity: checksum_status()/reassembled_http_status() moved out of
main.cpp into a shared wireframe/packet_diagnostics.hpp so the GUI can
show the same -c/-a diagnostics for the selected packet without
duplicating the Ethernet/IPv4/TCP walk. Visually verified under Xvfb
with the same split-segment scenario used to verify -a on the CLI.
Two new L7 dissectors: mDNS (reuses parse_dns outright - RFC 6762
keeps DNS's wire format, just a different port) and SSH's cleartext
identification banner. Live-verified against this machine's real
sshd and a real DNS-wire-format packet sent to port 5353.
Two new fuzz harnesses (fuzz_checksum, fuzz_tcp_reassembly) covering
code added here that the original nine harnesses never
touched. All 12 run clean across ~90M executions with no crashes.
NAMES.md and PLAN.md updated with this round's decisions and naming
candidates.
| -rw-r--r-- | CMakeLists.txt | 4 | ||||
| -rw-r--r-- | NAMES.md | 39 | ||||
| -rw-r--r-- | PLAN.md | 60 | ||||
| -rw-r--r-- | fuzz/fuzz_checksum.cpp | 27 | ||||
| -rw-r--r-- | fuzz/fuzz_tcp_reassembly.cpp | 42 | ||||
| -rw-r--r-- | include/wireframe/l7/mdns.hpp | 44 | ||||
| -rw-r--r-- | include/wireframe/l7/ssh.hpp | 65 | ||||
| -rw-r--r-- | include/wireframe/packet_diagnostics.hpp | 92 | ||||
| -rw-r--r-- | include/wireframe/summarize.hpp | 12 | ||||
| -rw-r--r-- | src/gui_main.cpp | 45 | ||||
| -rw-r--r-- | src/main.cpp | 105 | ||||
| -rw-r--r-- | tests/test_mdns.cpp | 60 | ||||
| -rw-r--r-- | tests/test_ssh.cpp | 62 |
13 files changed, 553 insertions, 104 deletions
diff --git a/CMakeLists.txt b/CMakeLists.txt index abe3546..264fcc4 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -99,6 +99,8 @@ add_executable(wireframe_tests tests/test_net.cpp tests/test_ipv6.cpp tests/test_dns.cpp + tests/test_mdns.cpp + tests/test_ssh.cpp tests/test_http.cpp tests/test_tls.cpp tests/test_pcapng.cpp @@ -147,4 +149,6 @@ if(WIREFRAME_ENABLE_FUZZING) add_wireframe_fuzz_target(fuzz_tls) add_wireframe_fuzz_target(fuzz_pcapng_reader) add_wireframe_fuzz_target(fuzz_summarize) + add_wireframe_fuzz_target(fuzz_checksum) + add_wireframe_fuzz_target(fuzz_tcp_reassembly) endif() @@ -102,3 +102,42 @@ No changes to the recommendation above - `frameshark`/`spanshark` (brand lineage) or `peek`/`probe` (terse-CLI lane) are still the strongest picks. `octet` is the one addition here worth weighing seriously: it's the most precise single word for what the tool actually operates on. + +## More candidates (added after TCP reassembly, checksums, privilege +## dropping, and a wider L7 protocol set - DNS/mDNS/HTTP/TLS SNI/SSH/ICMP) + +The project has since grown two angles the earlier lists didn't have +anything for: **stitching segments back into a stream** (TCP +reassembly, wireframe/net/tcp_reassembly.hpp) and **actively dropping +root** the moment the capture handle is open (wireframe/privileges.hpp) +rather than just capturing passively. + +- `flowtap` - "flow" is the actual industry term for what + TcpReassembler tracks (a 4-tuple's worth of state across many + packets), not just "stream" +- `stitchtap` - literal, describes reassembly specifically; maybe too + literal/cute +- `reflow` - re- (reassemble) + flow; short, but collides conceptually + with CSS/text "reflow", possibly confusing +- `dropcap` - pun on dropping root/CAP_NET_RAW after opening the + capture handle, which doubles as an actual typography term ("drop + cap": an oversized first letter) - two real meanings landing on the + same word is rare enough to be worth serious consideration +- `polytap` - poly- (many protocols: DNS/HTTP/TLS/mDNS/SSH/ICMP) + tap, + keeps the -tap suffix family from the first list +- `layershark` / `stackshark` - extends the -shark lineage with the + OSI-layer angle (L2 through L7 all decoded by hand now) +- `dissect` - plain English word, no jargon, describes exactly what + the tool does at every layer; downside is it's a very generic verb, + likely to collide with something already using it + +## Current standing recommendation + +Given how much the project now actually does - full L2-L7 decode +(including reassembly), pcapng, filtering, checksum verification, +privilege dropping, dual TUI/GUI frontends - a name that still reads +as "one narrow tool" undersells it less than it used to when this list +started. `frameshark` remains the strongest brand-lineage pick; +`dropcap` is the strongest new candidate from this round, on the +strength of its double meaning actually being true of the tool's own +behavior rather than a stretch. @@ -30,8 +30,9 @@ unowned buffers) via a real-world capture pipeline. 3. [done] pcapng read/write 4. [done] Bounded channel + drop-on-backpressure between capture and render 5. [in progress] L7 dissector interface, add protocols incrementally -- - interface + DNS + HTTP + TLS SNI done (wireframe/l7/); more - protocols can still be added incrementally, by design + interface + DNS + HTTP + TLS SNI + mDNS + SSH banner done + (wireframe/l7/); more protocols can still be added incrementally, + by design 6. [done] Filtering (-f <expr>, libpcap's own BPF compiler - see Decisions) 7. [done] Drop privileges after opening the capture handle (see Decisions) 8. [done] TCP stream reassembly, opt-in via -a (see Decisions) @@ -312,3 +313,58 @@ None currently open. without needing active FIN/RST-triggered flow teardown - simpler, and stale entries past those caps don't affect correctness, just bounded memory use. +- GUI parity for -c/-a: checksum_status() and reassembled_http_status() + moved out of main.cpp into a new shared header + (wireframe/packet_diagnostics.hpp) rather than duplicated into + gui_main.cpp - the same reasoning wireframe::CaptureSession exists + for at the setup layer, applied here to the diagnostics layer. GUI's + hex dump was already always-on for the selected row (no -x-equivalent + flag needed); checksum status is computed lazily when a row is + selected (stateless, cheap); reassembled HTTP status has to be + computed at consume time instead (reassembly needs in-order state + across packets), so PacketRow gained an + optional<string> reassembled_http field set once in consumer_loop. + Both are still opt-in via the same -c/-a flag names as the CLI, off + by default. Visually verified under Xvfb (python-xlib synthetic + click) with the same split-segment scenario used to verify -a on the + CLI: selecting the packet whose segment completed the request showed + both "checksums: IP=ok TCP=BAD" and "reassembled request: GET + /split-test Host: split.example.com (72 bytes so far)" together in + the details pane, and a second run with neither flag confirmed both + lines are absent by default. The TCP=BAD reading on lo in that + screenshot is the checksum-offload caveat working as documented, not + a bug - Linux's loopback receive path typically never computes a + real TCP checksum at all (CHECKSUM_UNNECESSARY), which is exactly the + false-positive scenario -c's opt-in-ness exists to guard against. +- mDNS (wireframe/l7/mdns.hpp) and SSH banner (wireframe/l7/ssh.hpp) + dissectors, registered alongside DNS/HTTP/TLS in l7_registry(). + mDNS reuses parse_dns() outright - RFC 6762 keeps DNS's exact wire + format, just over UDP 5353 instead of 53 - and deliberately omits + the id= field DNS's own summary shows, since RFC 6762 18.1 has + multicast queries send it as zero, which would just be "id=0" noise + on every real packet. SSH's identification banner (RFC 4253 4.2) is + the one part of an SSH connection ever sent in the clear - a single + line before key exchange encrypts everything else - so unlike every + other dissector here, there's structurally nothing further to add to + it later. Live-verified against real traffic: SSH against this + machine's actual running sshd via /dev/tcp on lo, correctly decoding + "SSH 2.0 OpenSSH_10.4" (matching the real installed OpenSSH version); + mDNS via a real DNS-wire-format query sent to 127.0.0.1:5353 (no + avahi/mDNS responder running on this sandboxed machine, so a + synthetic-but-wire-format-real packet substituted for organic + traffic), correctly decoding "mDNS query myhost.local type=1" with no + id= field present. +- Fuzzing: two new libFuzzer harnesses added alongside the original + nine - fuzz_checksum (internet_checksum/verify_ipv4_checksum + directly, plus verify_tcp/udp_checksum_ipv4 with the first 8 input + bytes providing src/dst addresses) and fuzz_tcp_reassembly (unlike + every other harness, drives *one* TcpReassembler with a whole + sequence of segments parsed out of a single input, since the + interesting bugs in cross-call state - the flow map, per-direction + sequence tracking, the buffer cap - don't show up from one segment + alone). All 12 harnesses (the original nine plus these two) run + clean - no crashes, no ASan/UBSan errors, no leak/timeout artifacts + - across roughly 90 million total executions in a 20-second-each + pass; summarize's own harness also now exercises the ICMP decode path + added earlier this session and the new mDNS/SSH dissectors, since all + of that lives inside summarize_packet()'s call graph already. diff --git a/fuzz/fuzz_checksum.cpp b/fuzz/fuzz_checksum.cpp new file mode 100644 index 0000000..f490d1c --- /dev/null +++ b/fuzz/fuzz_checksum.cpp @@ -0,0 +1,27 @@ +#include <cstddef> +#include <cstdint> + +#include "wireframe/net/checksum.hpp" +#include "wireframe/net/ipv4.hpp" + +using namespace wireframe::net; + +// internet_checksum() itself takes arbitrary bytes directly. The +// verify_*_checksum_ipv4() wrappers additionally need two addresses, +// so the first 8 bytes of input become src/dst and the rest is treated +// as the header/segment/datagram under test - exercises the +// pseudo-header construction (detail::build_ipv4_pseudo_header) along +// with the checksum math itself. +extern "C" int LLVMFuzzerTestOneInput(const uint8_t* data, size_t size) { + internet_checksum({data, size}); + verify_ipv4_checksum({data, size}); + + if (size < 8) return 0; + Ipv4Address src{{data[0], data[1], data[2], data[3]}}; + Ipv4Address dst{{data[4], data[5], data[6], data[7]}}; + std::span<const unsigned char> rest{data + 8, size - 8}; + + verify_tcp_checksum_ipv4(src, dst, rest); + verify_udp_checksum_ipv4(src, dst, rest); + return 0; +} diff --git a/fuzz/fuzz_tcp_reassembly.cpp b/fuzz/fuzz_tcp_reassembly.cpp new file mode 100644 index 0000000..78ca91c --- /dev/null +++ b/fuzz/fuzz_tcp_reassembly.cpp @@ -0,0 +1,42 @@ +#include <cstddef> +#include <cstdint> + +#include "wireframe/net/tcp_reassembly.hpp" + +using namespace wireframe::net; + +// Unlike the other fuzz harnesses, this drives *one* TcpReassembler +// with a whole sequence of segments parsed out of a single input -- +// the interesting bugs here are in cross-call state (the flow map, +// per-direction sequence tracking, the buffer-size cap), not in +// decoding one segment alone. Each record is a fixed 19-byte header +// (src ip/port, dst ip/port, seq, flags, a payload length) followed by +// that many payload bytes; malformed/truncated trailing records are +// simply skipped rather than treated as an error, same as any other +// best-effort parse in this project. +extern "C" int LLVMFuzzerTestOneInput(const uint8_t* data, size_t size) { + TcpReassembler reassembler; + + size_t pos = 0; + while (pos + 19 <= size) { + Ipv4Address src{{data[pos], data[pos + 1], data[pos + 2], data[pos + 3]}}; + Ipv4Address dst{{data[pos + 4], data[pos + 5], data[pos + 6], data[pos + 7]}}; + std::uint16_t src_port = static_cast<std::uint16_t>(data[pos + 8] << 8 | data[pos + 9]); + std::uint16_t dst_port = static_cast<std::uint16_t>(data[pos + 10] << 8 | data[pos + 11]); + std::uint32_t seq = static_cast<std::uint32_t>(data[pos + 12]) << 24 | + static_cast<std::uint32_t>(data[pos + 13]) << 16 | + static_cast<std::uint32_t>(data[pos + 14]) << 8 | data[pos + 15]; + std::uint8_t flags = data[pos + 16]; + std::uint16_t payload_len = + static_cast<std::uint16_t>(data[pos + 17] << 8 | data[pos + 18]); + pos += 19; + + std::size_t available = size - pos; + std::size_t take = payload_len < available ? payload_len : available; + std::span<const unsigned char> payload{data + pos, take}; + pos += take; + + reassembler.process_segment(src, src_port, dst, dst_port, seq, flags, payload); + } + return 0; +} diff --git a/include/wireframe/l7/mdns.hpp b/include/wireframe/l7/mdns.hpp new file mode 100644 index 0000000..887d811 --- /dev/null +++ b/include/wireframe/l7/mdns.hpp @@ -0,0 +1,44 @@ +#pragma once + +#include <cstdint> +#include <optional> +#include <span> +#include <string> + +#include "wireframe/l7/dissector.hpp" +#include "wireframe/l7/dns.hpp" + +// mDNS (RFC 6762) reuses DNS's exact wire format - same header layout, +// same question/name encoding - just over a different port (5353, +// usually to/from the multicast address 224.0.0.251) and typically +// with many questions/answers per packet instead of DNS's usual one. +// parse_dns() already only looks at the first question, which is true +// here too; the only real difference worth a label is which protocol +// this traffic actually is, so real-world capture output doesn't read +// "DNS" for traffic that never touched a resolver. +namespace wireframe::net { + +inline constexpr std::uint16_t kMdnsPort = 5353; + +class MdnsDissector : public L7Dissector { +public: + std::uint16_t port() const override { return kMdnsPort; } + + std::optional<std::string> summarize(std::span<const unsigned char> payload) const override { + auto msg = parse_dns(payload); + if (!msg) return std::nullopt; + + // No id= field here unlike DnsDissector's summary: RFC 6762 + // 18.1 has multicast queries send it as zero, so printing it + // would just be "id=0" noise on real traffic. + std::string out = "mDNS "; + out += msg->header.is_response ? "response" : "query"; + if (msg->header.is_response) out += " ancount=" + std::to_string(msg->header.ancount); + if (msg->question) { + out += " " + msg->question->name + " type=" + std::to_string(msg->question->qtype); + } + return out; + } +}; + +} // namespace wireframe::net diff --git a/include/wireframe/l7/ssh.hpp b/include/wireframe/l7/ssh.hpp new file mode 100644 index 0000000..efa471f --- /dev/null +++ b/include/wireframe/l7/ssh.hpp @@ -0,0 +1,65 @@ +#pragma once + +#include <cstdint> +#include <optional> +#include <span> +#include <string> +#include <string_view> + +#include "wireframe/l7/dissector.hpp" + +// SSH's identification exchange (RFC 4253 section 4.2) is the one part +// of an SSH connection sent in the clear, before key exchange starts +// encrypting everything: both sides open with a single line of the +// form "SSH-protoversion-softwareversion[ comments]" terminated by +// CR LF (a bare LF is tolerated too, same leniency this project's HTTP +// dissector already uses). Only that first line is ever readable -- +// everything after key exchange is opaque, so this dissector only ever +// has one line to look at, on either side of the connection. +namespace wireframe::net { + +inline constexpr std::uint16_t kSshPort = 22; + +struct SshBanner { + std::string proto_version; + std::string software_version; +}; + +inline std::optional<SshBanner> parse_ssh_banner(std::span<const unsigned char> payload) { + std::string_view text(reinterpret_cast<const char*>(payload.data()), payload.size()); + if (text.substr(0, 4) != "SSH-") return std::nullopt; + + std::size_t line_end = text.find("\r\n"); + if (line_end == std::string_view::npos) { + line_end = text.find('\n'); + if (line_end == std::string_view::npos) return std::nullopt; + } + std::string_view line = text.substr(4, line_end - 4); // past "SSH-" + + std::size_t dash = line.find('-'); + if (dash == std::string_view::npos) return std::nullopt; + + SshBanner banner; + banner.proto_version = std::string(line.substr(0, dash)); + + // The software version runs up to the first space (start of an + // optional comment) or the end of the line, whichever is first. + std::string_view rest = line.substr(dash + 1); + std::size_t space = rest.find(' '); + banner.software_version = std::string(space == std::string_view::npos ? rest + : rest.substr(0, space)); + return banner; +} + +class SshDissector : public L7Dissector { +public: + std::uint16_t port() const override { return kSshPort; } + + std::optional<std::string> summarize(std::span<const unsigned char> payload) const override { + auto banner = parse_ssh_banner(payload); + if (!banner) return std::nullopt; + return "SSH " + banner->proto_version + " " + banner->software_version; + } +}; + +} // namespace wireframe::net diff --git a/include/wireframe/packet_diagnostics.hpp b/include/wireframe/packet_diagnostics.hpp new file mode 100644 index 0000000..4b9b0c6 --- /dev/null +++ b/include/wireframe/packet_diagnostics.hpp @@ -0,0 +1,92 @@ +#pragma once + +#include <optional> +#include <pcap.h> +#include <span> +#include <string> + +#include "wireframe/l7/http.hpp" +#include "wireframe/net/checksum.hpp" +#include "wireframe/net/ethernet.hpp" +#include "wireframe/net/ipv4.hpp" +#include "wireframe/net/tcp.hpp" +#include "wireframe/net/tcp_reassembly.hpp" + +// Checksum validation and TCP stream reassembly are both deliberately +// kept out of summarize_packet()'s shared per-packet output - see +// wireframe/net/checksum.hpp and wireframe/net/tcp_reassembly.hpp for +// why each is opt-in (checksum offload false positives; reassembly's +// per-flow state and extra per-packet work). Shared between the CLI +// (-c/-a) and GUI frontends so they don't hand-roll two separate +// Ethernet/IPv4/TCP walks down to the same byte spans - the same +// reasoning wireframe::CaptureSession exists for at the setup layer. +namespace wireframe { + +inline std::string checksum_status(std::span<const unsigned char> bytes, int datalink) { + std::span<const unsigned char> ip_bytes; + if (datalink == DLT_RAW) { + ip_bytes = bytes; + } else { + auto eth = net::parse_ethernet(bytes); + if (!eth || eth->header.ethertype != net::kEthertypeIPv4) return ""; + ip_bytes = eth->payload; + } + if (ip_bytes.empty() || (ip_bytes[0] >> 4) != 4) return ""; // only IPv4 checksums, for now + + auto ip = net::parse_ipv4(ip_bytes); + if (!ip) return ""; + + std::size_t header_len = static_cast<std::size_t>(ip->header.ihl) * 4; + std::string out = "checksums: IP="; + out += net::verify_ipv4_checksum(ip_bytes.first(header_len)) ? "ok" : "BAD"; + + using net::ChecksumResult; + if (ip->header.protocol == net::kProtoTcp) { + auto result = net::verify_tcp_checksum_ipv4(ip->header.src, ip->header.dst, ip->payload); + out += result == ChecksumResult::kValid ? " TCP=ok" : " TCP=BAD"; + } else if (ip->header.protocol == net::kProtoUdp) { + auto result = net::verify_udp_checksum_ipv4(ip->header.src, ip->header.dst, ip->payload); + out += result == ChecksumResult::kValid ? " UDP=ok" + : result == ChecksumResult::kNotPresent ? " UDP=none" + : " UDP=BAD"; + } + return out; +} + +inline std::optional<std::string> reassembled_http_status(std::span<const unsigned char> bytes, + int datalink, + net::TcpReassembler& reassembler) { + std::span<const unsigned char> ip_bytes; + if (datalink == DLT_RAW) { + ip_bytes = bytes; + } else { + auto eth = net::parse_ethernet(bytes); + if (!eth || eth->header.ethertype != net::kEthertypeIPv4) return std::nullopt; + ip_bytes = eth->payload; + } + if (ip_bytes.empty() || (ip_bytes[0] >> 4) != 4) return std::nullopt; // IPv4 only, for now + + auto ip = net::parse_ipv4(ip_bytes); + if (!ip || ip->header.protocol != net::kProtoTcp) return std::nullopt; + + auto tcp = net::parse_tcp(ip->payload); + if (!tcp) return std::nullopt; + + auto reassembled = reassembler.process_segment(ip->header.src, tcp->header.src_port, + ip->header.dst, tcp->header.dst_port, + tcp->header.seq, tcp->header.flags, + tcp->payload); + if (!reassembled) return std::nullopt; + + auto http = net::parse_http(*reassembled); + if (!http) return std::nullopt; + + std::string out = "reassembled "; + out += http->is_request ? "request: " : "response: "; + out += http->method_or_version + " " + http->target_or_status; + if (http->host) out += " Host: " + *http->host; + out += " (" + std::to_string(reassembled->size()) + " bytes so far)"; + return out; +} + +} // namespace wireframe diff --git a/include/wireframe/summarize.hpp b/include/wireframe/summarize.hpp index e7e9ae3..840ddf9 100644 --- a/include/wireframe/summarize.hpp +++ b/include/wireframe/summarize.hpp @@ -11,6 +11,8 @@ #include "wireframe/l7/dissector.hpp" #include "wireframe/l7/dns.hpp" #include "wireframe/l7/http.hpp" +#include "wireframe/l7/mdns.hpp" +#include "wireframe/l7/ssh.hpp" #include "wireframe/l7/tls.hpp" #include "wireframe/net/ethernet.hpp" #include "wireframe/net/icmp.hpp" @@ -57,16 +59,24 @@ inline std::string tcp_flags_to_string(std::uint8_t flags) { // DNS alone never did, since it only ever runs over UDP port 53. TLS // (also TCP, port 443) covers what HTTP increasingly can't: most web // traffic today is encrypted, and SNI is the one piece of a TLS -// handshake still readable without decrypting anything. +// handshake still readable without decrypting anything. mDNS reuses +// DNS's own parser (same wire format, different port/label) at +// essentially no extra cost. SSH is the first dissector whose *entire* +// protocol is one cleartext line before everything else encrypts -- +// unlike TLS's SNI, there's nothing further to ever add here. inline const net::L7Registry& l7_registry() { static const net::DnsDissector dns_dissector; static const net::HttpDissector http_dissector; static const net::TlsSniDissector tls_dissector; + static const net::MdnsDissector mdns_dissector; + static const net::SshDissector ssh_dissector; static const net::L7Registry registry = [] { net::L7Registry r; r.add(&dns_dissector); r.add(&http_dissector); r.add(&tls_dissector); + r.add(&mdns_dissector); + r.add(&ssh_dissector); return r; }(); return registry; diff --git a/src/gui_main.cpp b/src/gui_main.cpp index 16ee769..8a7771a 100644 --- a/src/gui_main.cpp +++ b/src/gui_main.cpp @@ -23,6 +23,8 @@ #include <thread> #include "wireframe/capture_session.hpp" +#include "wireframe/net/tcp_reassembly.hpp" +#include "wireframe/packet_diagnostics.hpp" #include "wireframe/search.hpp" #include "wireframe/summarize.hpp" @@ -31,6 +33,11 @@ namespace { struct PacketRow { std::string summary; std::vector<unsigned char> data; + // -a only: computed once at consume time (reassembly needs + // in-order state across packets, unlike checksum status below, + // which is stateless and cheap enough to compute lazily when a row + // is selected instead of storing it on every row). + std::optional<std::string> reassembled_http; }; constexpr std::size_t kMaxRows = 5000; // cap memory; oldest rows scroll off @@ -52,7 +59,7 @@ struct SharedState { }; void consumer_loop(wireframe::CaptureSession& session, wireframe::CaptureQueue& queue, - SharedState& state) { + SharedState& state, wireframe::net::TcpReassembler* reassembler) { while (auto packet = queue.pop()) { std::span<const unsigned char> bytes{packet->data}; std::string summary = wireframe::summarize_packet(bytes, session.datalink()); @@ -62,8 +69,15 @@ void consumer_loop(wireframe::CaptureSession& session, wireframe::CaptureQueue& packet->original_len); } + std::optional<std::string> reassembled_http; + if (reassembler) { + reassembled_http = wireframe::reassembled_http_status(bytes, session.datalink(), + *reassembler); + } + std::lock_guard<std::mutex> lock(state.mutex); - state.rows.push_back({std::move(summary), std::move(packet->data)}); + state.rows.push_back({std::move(summary), std::move(packet->data), + std::move(reassembled_http)}); if (state.rows.size() > kMaxRows) state.rows.pop_front(); ++state.packet_count; } @@ -89,6 +103,11 @@ void print_usage(const char* argv0) { " -r <file> Replay a saved pcapng file instead of a live device\n" " -f <expr> Kernel-level capture filter (tcpdump/BPF syntax); also\n" " applies to what -w writes. Can't be combined with -r.\n" + " -c Show IPv4/TCP/UDP checksum validity for the selected packet.\n" + " Off by default - see the CLI's -h for why (checksum offload).\n" + " -a Reassemble TCP streams and show HTTP requests/responses\n" + " joined across segments for the selected packet, when its\n" + " segment contributed to one. In-order segments only.\n" " -h, --help Show this help and exit\n" "\n" "Examples:\n" @@ -109,6 +128,8 @@ int main(int argc, char** argv) { } wireframe::CaptureSessionOptions options; + bool enable_checksums = false; + bool enable_reassembly = false; for (int i = 1; i < argc; ++i) { if (std::strcmp(argv[i], "-w") == 0 && i + 1 < argc) { options.pcapng_output_path = argv[++i]; @@ -116,6 +137,10 @@ int main(int argc, char** argv) { options.filter_expr = argv[++i]; } else if (std::strcmp(argv[i], "-r") == 0 && i + 1 < argc) { options.replay_input_path = argv[++i]; + } else if (std::strcmp(argv[i], "-c") == 0) { + enable_checksums = true; + } else if (std::strcmp(argv[i], "-a") == 0) { + enable_reassembly = true; } else if (options.device.empty()) { options.device = argv[i]; } @@ -158,9 +183,10 @@ int main(int argc, char** argv) { wireframe::CaptureQueue queue(4096); SharedState state; + wireframe::net::TcpReassembler reassembler; std::thread capture_thread = session.start_capture_thread(queue); std::thread consumer_thread(consumer_loop, std::ref(session), std::ref(queue), - std::ref(state)); + std::ref(state), enable_reassembly ? &reassembler : nullptr); int selected_row = -1; bool quit = false; @@ -248,7 +274,18 @@ int main(int argc, char** argv) { { std::lock_guard<std::mutex> lock(state.mutex); if (selected_row >= 0 && selected_row < static_cast<int>(state.rows.size())) { - for (const auto& line : wireframe::hex_dump_lines(state.rows[selected_row].data)) { + const auto& row = state.rows[selected_row]; + if (enable_checksums) { + std::string status = wireframe::checksum_status(row.data, session.datalink()); + if (!status.empty()) { + ImGui::TextColored(ImVec4(0.6f, 0.8f, 1.0f, 1.0f), "%s", status.c_str()); + } + } + if (row.reassembled_http) { + ImGui::TextColored(ImVec4(0.6f, 1.0f, 0.6f, 1.0f), "%s", + row.reassembled_http->c_str()); + } + for (const auto& line : wireframe::hex_dump_lines(row.data)) { ImGui::TextUnformatted(line.c_str()); } } else { diff --git a/src/main.cpp b/src/main.cpp index 31fca73..72dd267 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -48,12 +48,8 @@ #include <ftxui/dom/elements.hpp> #include "wireframe/capture_session.hpp" -#include "wireframe/l7/http.hpp" -#include "wireframe/net/checksum.hpp" -#include "wireframe/net/ethernet.hpp" -#include "wireframe/net/ipv4.hpp" -#include "wireframe/net/tcp.hpp" #include "wireframe/net/tcp_reassembly.hpp" +#include "wireframe/packet_diagnostics.hpp" #include "wireframe/search.hpp" #include "wireframe/summarize.hpp" @@ -72,95 +68,6 @@ void hex_dump(std::span<const unsigned char> bytes) { std::printf("\n"); } -// -c only: checksum validation isn't part of summarize_packet()'s -// shared output (see wireframe/net/checksum.hpp for why - checksum -// offload makes it noise, not signal, on most of the interfaces this -// project has actually been tested against). IPv4 only for now; this -// does its own minimal walk down to the IP/TCP/UDP byte spans the -// checksum functions need, reusing the existing decoders rather than -// duplicating their parsing logic. -std::string checksum_status(std::span<const unsigned char> bytes, int datalink) { - std::span<const unsigned char> ip_bytes; - if (datalink == DLT_RAW) { - ip_bytes = bytes; - } else { - auto eth = wireframe::net::parse_ethernet(bytes); - if (!eth || eth->header.ethertype != wireframe::net::kEthertypeIPv4) return ""; - ip_bytes = eth->payload; - } - if (ip_bytes.empty() || (ip_bytes[0] >> 4) != 4) return ""; // only IPv4 checksums, for now - - auto ip = wireframe::net::parse_ipv4(ip_bytes); - if (!ip) return ""; - - std::size_t header_len = static_cast<std::size_t>(ip->header.ihl) * 4; - std::string out = " checksums: IP="; - out += wireframe::net::verify_ipv4_checksum(ip_bytes.first(header_len)) ? "ok" : "BAD"; - - using wireframe::net::ChecksumResult; - if (ip->header.protocol == wireframe::net::kProtoTcp) { - auto result = - wireframe::net::verify_tcp_checksum_ipv4(ip->header.src, ip->header.dst, ip->payload); - out += result == ChecksumResult::kValid ? " TCP=ok" : " TCP=BAD"; - } else if (ip->header.protocol == wireframe::net::kProtoUdp) { - auto result = - wireframe::net::verify_udp_checksum_ipv4(ip->header.src, ip->header.dst, ip->payload); - out += result == ChecksumResult::kValid ? " UDP=ok" - : result == ChecksumResult::kNotPresent ? " UDP=none" - : " UDP=BAD"; - } - return out; -} - -// -a only: TCP stream reassembly (wireframe/net/tcp_reassembly.hpp), -// re-run through the same HTTP dissector summarize_packet() already -// uses for a single segment - reassembly only helps when a message is -// actually split across packets, and HTTP is the L7 dissector in this -// project that's structured around lines/headers rather than one fixed -// datagram (DNS/TLS ClientHello are each their own single UDP datagram -// or first TCP segment already). Printed as its own line rather than -// folded into the per-packet summary: it reflects accumulated flow -// state, not just this one packet. In-order-only reassembly (see the -// header's own comment) means this can legitimately fire again on a -// later packet of the same request with an unchanged result once the -// headers are already complete - an honest simplification, not -// deduplicated further. -std::optional<std::string> reassembled_http_status(std::span<const unsigned char> bytes, - int datalink, - wireframe::net::TcpReassembler& reassembler) { - std::span<const unsigned char> ip_bytes; - if (datalink == DLT_RAW) { - ip_bytes = bytes; - } else { - auto eth = wireframe::net::parse_ethernet(bytes); - if (!eth || eth->header.ethertype != wireframe::net::kEthertypeIPv4) return std::nullopt; - ip_bytes = eth->payload; - } - if (ip_bytes.empty() || (ip_bytes[0] >> 4) != 4) return std::nullopt; // IPv4 only, for now - - auto ip = wireframe::net::parse_ipv4(ip_bytes); - if (!ip || ip->header.protocol != wireframe::net::kProtoTcp) return std::nullopt; - - auto tcp = wireframe::net::parse_tcp(ip->payload); - if (!tcp) return std::nullopt; - - auto reassembled = reassembler.process_segment(ip->header.src, tcp->header.src_port, - ip->header.dst, tcp->header.dst_port, - tcp->header.seq, tcp->header.flags, - tcp->payload); - if (!reassembled) return std::nullopt; - - auto http = wireframe::net::parse_http(*reassembled); - if (!http) return std::nullopt; - - std::string out = " [reassembled "; - out += http->is_request ? "request] " : "response] "; - out += http->method_or_version + " " + http->target_or_status; - if (http->host) out += " Host: " + *http->host; - out += " (" + std::to_string(reassembled->size()) + " bytes so far)"; - return out; -} - struct RenderOptions { bool verbose_hex; bool verbose_checksums; @@ -185,11 +92,15 @@ void render_packet(const wireframe::CapturedPacket& packet, const RenderOptions& if (!wireframe::matches_search(line, opts.search_term)) return; - if (opts.verbose_checksums) line += checksum_status(bytes, opts.datalink); + if (opts.verbose_checksums) { + std::string status = wireframe::checksum_status(bytes, opts.datalink); + if (!status.empty()) line += " " + status; + } std::printf("%s\n", line.c_str()); if (opts.reassembler) { - if (auto status = reassembled_http_status(bytes, opts.datalink, *opts.reassembler)) { - std::printf("%s\n", status->c_str()); + if (auto status = wireframe::reassembled_http_status(bytes, opts.datalink, + *opts.reassembler)) { + std::printf(" [%s]\n", status->c_str()); } } if (opts.verbose_hex) hex_dump(bytes); diff --git a/tests/test_mdns.cpp b/tests/test_mdns.cpp new file mode 100644 index 0000000..cad77e7 --- /dev/null +++ b/tests/test_mdns.cpp @@ -0,0 +1,60 @@ +#include <doctest/doctest.h> + +#include <vector> + +#include "wireframe/l7/mdns.hpp" + +using namespace wireframe::net; + +namespace { + +// A typical mDNS query for a ".local" hostname, id=0 per RFC 6762 +// 18.1's convention for multicast queries. +std::vector<unsigned char> mdns_query() { + return { + 0x00, 0x00, // id = 0 + 0x00, 0x00, // flags: query + 0x00, 0x01, // qdcount = 1 + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 6, 'm', 'y', 'h', 'o', 's', 't', 5, 'l', 'o', 'c', 'a', 'l', 0, + 0x00, 0x01, // qtype = A + 0x00, 0x01, // qclass = IN + }; +} + +} // namespace + +TEST_CASE("MdnsDissector claims port 5353") { + MdnsDissector dissector; + CHECK(dissector.port() == kMdnsPort); +} + +TEST_CASE("MdnsDissector summarizes a query without an id= field") { + MdnsDissector dissector; + auto summary = dissector.summarize(mdns_query()); + REQUIRE(summary.has_value()); + CHECK(summary->substr(0, 10) == "mDNS query"); + CHECK(summary->find("myhost.local") != std::string::npos); + CHECK(summary->find("id=") == std::string::npos); +} + +TEST_CASE("MdnsDissector summarizes a response with ancount") { + MdnsDissector dissector; + std::vector<unsigned char> bytes = { + 0x00, 0x00, + 0x84, 0x00, // flags: QR=1 (response), AA=1 + 0x00, 0x00, // qdcount = 0 (typical for an mDNS response) + 0x00, 0x01, // ancount = 1 + 0x00, 0x00, 0x00, 0x00, + }; + auto summary = dissector.summarize(bytes); + REQUIRE(summary.has_value()); + CHECK(summary->substr(0, 13) == "mDNS response"); + CHECK(summary->find("ancount=1") != std::string::npos); +} + +TEST_CASE("MdnsDissector::summarize returns nullopt for a truncated payload") { + MdnsDissector dissector; + std::vector<unsigned char> bytes(5, 0); + CHECK_FALSE(dissector.summarize(bytes).has_value()); +} diff --git a/tests/test_ssh.cpp b/tests/test_ssh.cpp new file mode 100644 index 0000000..7c4e339 --- /dev/null +++ b/tests/test_ssh.cpp @@ -0,0 +1,62 @@ +#include <doctest/doctest.h> + +#include <vector> + +#include "wireframe/l7/ssh.hpp" + +using namespace wireframe::net; + +namespace { + +std::vector<unsigned char> to_bytes(const std::string& s) { + return std::vector<unsigned char>(s.begin(), s.end()); +} + +} // namespace + +TEST_CASE("parse_ssh_banner decodes a CRLF-terminated banner with a comment") { + auto banner = parse_ssh_banner(to_bytes("SSH-2.0-OpenSSH_9.6 FreeBSD-20240101\r\n")); + REQUIRE(banner.has_value()); + CHECK(banner->proto_version == "2.0"); + CHECK(banner->software_version == "OpenSSH_9.6"); +} + +TEST_CASE("parse_ssh_banner decodes a banner with no comment") { + auto banner = parse_ssh_banner(to_bytes("SSH-2.0-libssh_0.10.6\r\n")); + REQUIRE(banner.has_value()); + CHECK(banner->proto_version == "2.0"); + CHECK(banner->software_version == "libssh_0.10.6"); +} + +TEST_CASE("parse_ssh_banner tolerates a bare LF terminator") { + auto banner = parse_ssh_banner(to_bytes("SSH-1.99-OpenSSH_3.9\n")); + REQUIRE(banner.has_value()); + CHECK(banner->proto_version == "1.99"); + CHECK(banner->software_version == "OpenSSH_3.9"); +} + +TEST_CASE("parse_ssh_banner rejects payloads without the SSH- prefix") { + CHECK_FALSE(parse_ssh_banner(to_bytes("not an ssh banner\r\n")).has_value()); +} + +TEST_CASE("parse_ssh_banner rejects a banner missing the version separator") { + CHECK_FALSE(parse_ssh_banner(to_bytes("SSH-nodash\r\n")).has_value()); +} + +TEST_CASE("parse_ssh_banner rejects an unterminated line") { + CHECK_FALSE(parse_ssh_banner(to_bytes("SSH-2.0-OpenSSH_9.6")).has_value()); +} + +TEST_CASE("SshDissector claims port 22 and its summary matches parse_ssh_banner") { + SshDissector dissector; + CHECK(dissector.port() == kSshPort); + + auto summary = dissector.summarize(to_bytes("SSH-2.0-OpenSSH_9.6\r\n")); + REQUIRE(summary.has_value()); + CHECK(*summary == "SSH 2.0 OpenSSH_9.6"); +} + +TEST_CASE("SshDissector::summarize returns nullopt for non-SSH payload") { + SshDissector dissector; + CHECK_FALSE(dissector.summarize(to_bytes("GET / HTTP/1.1\r\n")).has_value()); +} |