dsdv: LookForQueuedPackets() ignores a failed LookupRoute() and sends on a route with a null output device (assert in default, silent SIGSEGV in optimized)

Summary

dsdv::RoutingProtocol::LookForQueuedPackets() discards the return value of m_routingTable.LookupRoute() and forwards queued packets on the resulting default-constructed entry's route, whose output device is null. In a default (assert-enabled) build this dies at NS_ASSERT (interface >= 0) in Ipv4L3Protocol::SendRealOut (ipv4-l3-protocol.cc:960 on current master); in an optimized build the assert is compiled out and the same fault is a bare SIGSEGV with completely empty stderr (verified: exit 139, stderr 0 bytes).

The defect is deterministic on any node with more than one non-loopback interface (reproducer below — 3-node point-to-point chain, stock modules only), and is present unchanged from ns-3.36 through current master (b677fede, 2026-08-14); the code dates to the original 2010 contribution.

This is very likely the same crash as #503 (closed) (2021, wifi ad hoc, ~0.27 % of 7800 runs, closed 2025-06-09 "assuming it was fixed" — no fix was ever made; the code is byte-identical today, and that issue still carries module::internet although the fault is in dsdv).

Reproducer (stock modules only)

Drop the file below into scratch/ and run:

./ns3 run dsdv-multi-interface-crash

Topology: n0 --10.1.1.0/30-- n1 --10.1.2.0/30-- n2, one UDP flow n2→n0. Only n1 is multi-interface. Verified on ns-3.36, ns-3.48 and master b677fede, all crashing at the same simulated time:

node 0 has 1 non-loopback interface(s): 10.1.1.1
node 1 has 2 non-loopback interface(s): 10.1.1.2 10.1.2.1
node 2 has 1 non-loopback interface(s): 10.1.2.2
sending from node 2 to 10.1.1.1
--- starting simulator ---
NS_ASSERT failed, cond="interface >= 0", +1.512000000s 2 file=.../src/internet/model/ipv4-l3-protocol.cc, line=960

Controls on the identical topology: --nNodes=2 (single-interface) completes and delivers 9728 B; --protocol=aodv|olsr|rip complete and deliver (9728 / 8512 / 9728 B); --csma=1 crashes identically, so the device type is irrelevant. Under --build-profile=optimized the same run is a SIGSEGV with empty stderr — which is how it appears in CI logs, with nothing to search for.

scratch/dsdv-multi-interface-crash.cc
/*
 * Minimal standalone reproducer for an ns-3 DSDV defect.
 *
 * Stock ns-3 modules only: core, network, internet, point-to-point,
 * applications, dsdv.  No external contrib code.
 *
 * Topology: a three-node point-to-point chain.
 *
 *      n0 ---- 10.1.1.0/30 ---- n1 ---- 10.1.2.0/30 ---- n2
 *   10.1.1.1              10.1.1.2  10.1.2.1              10.1.2.2
 *
 * n1 is the only node with more than one non-loopback interface.
 */

#include "ns3/applications-module.h"
#include "ns3/core-module.h"
#include "ns3/csma-module.h"
#include "ns3/aodv-module.h"
#include "ns3/dsdv-module.h"
#include "ns3/olsr-module.h"
#include "ns3/internet-module.h"
#include "ns3/network-module.h"
#include "ns3/point-to-point-module.h"

using namespace ns3;

NS_LOG_COMPONENT_DEFINE("DsdvMultiInterfaceCrash");

int
main(int argc, char* argv[])
{
    uint32_t nNodes = 3;
    double stopTime = 40.0;
    bool useCsma = false;
    std::string protocol = "dsdv";

    CommandLine cmd(__FILE__);
    cmd.AddValue("nNodes", "Number of nodes in the chain (>=3 gives a multi-interface node)", nNodes);
    cmd.AddValue("stopTime", "Simulation stop time in seconds", stopTime);
    cmd.AddValue("csma", "Use CSMA links instead of point-to-point", useCsma);
    cmd.AddValue("protocol", "Routing protocol: dsdv | aodv | olsr | rip", protocol);
    cmd.Parse(argc, argv);

    NS_ABORT_MSG_IF(nNodes < 2, "need at least 2 nodes");

    NodeContainer nodes;
    nodes.Create(nNodes);

    PointToPointHelper p2p;
    p2p.SetDeviceAttribute("DataRate", StringValue("5Mbps"));
    p2p.SetChannelAttribute("Delay", StringValue("2ms"));

    CsmaHelper csma;
    csma.SetChannelAttribute("DataRate", StringValue("5Mbps"));
    csma.SetChannelAttribute("Delay", StringValue("2ms"));

    // One device per link. The interior nodes of the chain therefore each hold
    // two non-loopback interfaces -- exactly the shape of a satellite with
    // several inter-satellite links. The device type is irrelevant to the bug;
    // --csma selects CSMA to demonstrate that.
    std::vector<NetDeviceContainer> links;
    for (uint32_t i = 0; i + 1 < nNodes; ++i)
    {
        NodeContainer pair(nodes.Get(i), nodes.Get(i + 1));
        links.push_back(useCsma ? csma.Install(pair) : p2p.Install(pair));
    }

    // The routing protocol is selectable purely so the same topology can be
    // used to show that the sibling protocols survive it. Default is dsdv.
    InternetStackHelper internet;
    DsdvHelper dsdv;
    AodvHelper aodv;
    OlsrHelper olsr;
    RipHelper rip;
    if (protocol == "dsdv")
    {
        internet.SetRoutingHelper(dsdv);
    }
    else if (protocol == "aodv")
    {
        internet.SetRoutingHelper(aodv);
    }
    else if (protocol == "olsr")
    {
        internet.SetRoutingHelper(olsr);
    }
    else if (protocol == "rip")
    {
        internet.SetRoutingHelper(rip);
    }
    else
    {
        NS_ABORT_MSG("unknown protocol " << protocol);
    }
    internet.Install(nodes);

    // A distinct /30 per link, as an ISL mesh would use.
    Ipv4AddressHelper ipv4;
    std::vector<Ipv4InterfaceContainer> ifaces;
    for (uint32_t i = 0; i + 1 < nNodes; ++i)
    {
        std::ostringstream base;
        base << "10.1." << (i + 1) << ".0";
        ipv4.SetBase(base.str().c_str(), "255.255.255.252");
        ifaces.push_back(ipv4.Assign(links[i]));
    }

    // Report the interface count per node, so the run output shows the trigger
    // condition directly.
    for (uint32_t i = 0; i < nNodes; ++i)
    {
        Ptr<Ipv4> ip = nodes.Get(i)->GetObject<Ipv4>();
        std::cout << "node " << i << " has " << (ip->GetNInterfaces() - 1)
                  << " non-loopback interface(s):";
        for (uint32_t k = 1; k < ip->GetNInterfaces(); ++k)
        {
            std::cout << " " << ip->GetAddress(k, 0).GetLocal();
        }
        std::cout << std::endl;
    }

    // Traffic from the last node to the first: a path of (nNodes-1) hops, so
    // the route entry at the sender has hop count > 1.
    Ipv4Address dstAddr = ifaces.front().GetAddress(0); // 10.1.1.1, on n0
    uint16_t port = 9;

    PacketSinkHelper sink("ns3::UdpSocketFactory",
                          InetSocketAddress(Ipv4Address::GetAny(), port));
    ApplicationContainer sinkApp = sink.Install(nodes.Get(0));
    sinkApp.Start(Seconds(0.0));
    sinkApp.Stop(Seconds(stopTime));

    OnOffHelper onoff("ns3::UdpSocketFactory", InetSocketAddress(dstAddr, port));
    onoff.SetAttribute("OnTime", StringValue("ns3::ConstantRandomVariable[Constant=1]"));
    onoff.SetAttribute("OffTime", StringValue("ns3::ConstantRandomVariable[Constant=0]"));
    onoff.SetAttribute("DataRate", StringValue("2kbps"));
    onoff.SetAttribute("PacketSize", UintegerValue(64));
    ApplicationContainer srcApp = onoff.Install(nodes.Get(nNodes - 1));
    srcApp.Start(Seconds(1.0));
    srcApp.Stop(Seconds(stopTime));

    std::cout << "sending from node " << (nNodes - 1) << " to " << dstAddr << std::endl;
    std::cout << "--- starting simulator ---" << std::endl;

    Simulator::Stop(Seconds(stopTime));
    Simulator::Run();
    Simulator::Destroy();

    uint64_t rx = DynamicCast<PacketSink>(sinkApp.Get(0))->GetTotalRx();
    std::cout << "--- simulator finished without crashing; sink received " << rx << " bytes ---"
              << std::endl;
    return 0;
}

Mechanism

  1. DSDV advertises its own identity as m_ipv4->GetAddress (1, 0) — a hardcoded interface index (dsdv-routing-protocol.cc:862, :913 on master). n1 therefore announces only 10.1.1.2 on both links and never announces 10.1.2.1.
  2. Receivers set the next hop from the update's source address (:619). n2 hears n1 as 10.1.2.1 — an address no advertisement ever names — so n2 has no routing-table entry keyed on its own next hop.
  3. LookForQueuedPackets() calls m_routingTable.LookupRoute (rt.GetNextHop (), newrt) and ignores the return value (:1161). The lookup fails, newrt stays default-constructed.
  4. RoutingTableEntry's constructor unconditionally does m_ipv4Route = Create<Ipv4Route> () (dsdv-rtable.cc), so newrt.GetRoute () is a non-null Ptr<Ipv4Route> whose output device is null. Ipv4L3Protocol::SendRealOut's if (!route) guard passes, GetInterfaceForDevice (nullptr) returns −1, and the assert fires (or, optimized, −1 indexes the interface vector).

Because the route pointer is non-null, the NS_ASSERT (route) directly below the failed lookup can never fire — it guards nothing. Notably, MR !1652 (merged) ("dsdv: Fix order of assert to avoid segmentation fault", merged 2023-09) edited exactly these lines, moving that assert above its first use, while leaving the unchecked LookupRoute on the line above it untouched: the segfault that motivated that MR is, I believe, this one, and the hardened assert cannot catch it.

While the deterministic trigger is multi-interface, defect and trigger are separable: #503 (closed) hit the same three frames (SendRealOutSendPacketFromQueueLookForQueuedPackets) on a single-interface wifi network, intermittently. A failed lookup must never produce a send, regardless of what made the lookup fail.

Proposed fix

The guarded form of the identical idiom already exists eleven lines earlier in the same file, in RouteOutput (:330):

            else
            {
                RoutingTableEntry newrt;
                if (!m_routingTable.LookupRoute(rt.GetNextHop(), newrt))
                {
                    NS_LOG_LOGIC("No route to next hop "
                                 << rt.GetNextHop() << "; leaving packets queued for "
                                 << rt.GetDestination());
                    continue;
                }
                route = newrt.GetRoute();
                ...

I have this as a patch applying cleanly to master and am happy to open an MR.

One important caveat for whoever lands this: the one-line fix is a memory-safety fix, not a functional one. With it applied, the reproducer no longer crashes but delivers 0 bytes — the advertisement defect in step 1 means multi-interface DSDV still cannot route; packets stay queued forever. Fixing step 1 as well (per-interface advertisement, or a canonical main address as OLSR does with MID + m_mainAddress) restores delivery to parity with AODV/RIP on the same topology (9728 B, verified). I'd suggest treating that as a follow-up issue rather than blocking the crash fix on it, but the release notes for the crash fix should say plainly that multi-interface DSDV remains non-functional until the advertisement is fixed. (For scope: src/dsdv/doc/dsdv.rst documents no single-interface restriction, and DSDV's own LoopbackRoute() comment at :523-527 explicitly contemplates multi-interface nodes — so this is an internal inconsistency, not documented behaviour.)

A higher-leverage optional hardening: SendRealOut could treat interface < 0 as DROP_NO_ROUTE (mirroring its !route guard ten lines above) so this whole class of routing-protocol bug fails safely instead of asserting/indexing with −1 — DSDV is not the only in-tree protocol that constructs routes from unchecked lookups.

Environment

  • ns-3.36, ns-3.48, and ns-3-dev master b677fede0a664ecc4e80615c331dd7f07e58c1c0 (2026-08-14) — identical behaviour, identical line numbers on master vs 3.48
  • GCC 13.3.0, Linux x86-64; default and optimized profiles as noted

Found while building an inter-satellite-link mesh (every satellite is multi-interface by construction). The reproducer, controls, both build profiles, and the fix-restores-delivery claim were each run and verified as described above.