On 4 Sep 2026, at 16:41, Aaron Conole wrote:

> Eelco Chaudron <[email protected]> writes:
>
>> On 27 Aug 2026, at 17:23, Aaron Conole via dev wrote:
>>
>>> random_uint32() is a xorshift32 PRNG (see lib/random.c): it holds many
>>> properties including uniformity and reversibility, so recovering the
>>> internal state from any single observed output lets an attacker
>>> compute every other output of that (per-thread) stream, forward and
>>> backward.  Using it for anything security-relevant is documented in
>>> random.c itself as inappropriate.
>>>
>>> Most NAT tuple selection does not call random_uint32() directly.
>>> Instead, nat_get_unique_tuple() picks both the NAT address
>>> (get_addr_in_range()) and, in the common case, the NAT port
>>> (set_sport_range()/set_dport_range()) via nat_range_hash(), a
>>> deterministic hash of the flow tuple mixed with a single 32-bit value:
>>> ct->hash_basis.  That basis was previously drawn once from
>>> random_uint32() at conntrack_init() time.  Because it is a single,
>>> long-lived value on which every NAT address and default-port choice
>>> depends, an attacker able to recover the xorshift state feeding that
>>> one random_uint32() call--e.g. by observing any other output drawn
>>> from the same per-thread PRNG stream elsewhere in the process--could
>>> predict every NAT tuple ovs-vswitchd will assign.  In effect, it was
>>> derived from a shared, reversible state.
>>>
>>> Two narrower paths draw directly from random_uint32() per new
>>> connection rather than through the hash: the `nat(...,random)` port
>>> path, and the retry offset used when the hash-selected port collides
>>> with an existing connection under range congestion.  These are
>>> directly observable per-connection outputs.
>>>
>>> Fix this by drawing ct->hash_basis, and the two per-connection port
>>> paths above, from a cryptographic source: OpenSSL's PRNG when
>>> compiled against OpenSSL 1.1.0+ (checking RAND_status() to catch the
>>> case where it has not been seeded properly despite RAND_bytes()
>>> succeeding, the same check already used in lib/stream-ssl.c), falling
>>> back to the system entropy pool.
>>>
>>> If neither source can provide randomness, do not silently downgrade
>>> to the non-cryptographic PRNG.  nat_random_uint32() instead reports
>>> failure, and its callers propagate that as NAT tuple exhaustion:
>>> nat_get_unique_l4() and nat_get_unique_tuple() both return false, so
>>> the connection attempt is failed the same way as any other allocation
>>> exhaustion (see the nat_res_exhaustion path in conn_update_state()),
>>> and each occurrence increments the new conntrack_entropy_failed
>>> coverage counter so operators can see it happening.  There is also a
>>> new point for ovs-vswitchd failure - at the conntrack initialization
>>> if sufficient entropy cannot be pulled for ct->hash_basis.
>>>
>>> Signed-off-by: Aaron Conole <[email protected]>
>>
>> Hi Aaron,
>>
>> Thanks for the patch. See some comments below. As Ilya mentioned offline,
>> it might be good to get some performance numbers with this change to see
>> the impact of the RAND_bytes() usage.
>
> I'll do some testing.
>
>> //Eelco
>>
>>> ---
>>>  lib/conntrack.c | 76 ++++++++++++++++++++++++++++++++++++++++++++++---
>>>  1 file changed, 72 insertions(+), 4 deletions(-)
>>>
>>> diff --git a/lib/conntrack.c b/lib/conntrack.c
>>> index f84cdd216a..d7fa6bd746 100644
>>> --- a/lib/conntrack.c
>>> +++ b/lib/conntrack.c
>>> @@ -22,6 +22,10 @@
>>>  #include <netinet/icmp6.h>
>>>  #include <string.h>
>>>
>>> +#ifdef HAVE_OPENSSL
>>> +#include <openssl/rand.h>
>>> +#endif
>>> +
>>>  #include "conntrack.h"
>>>  #include "conntrack-private.h"
>>>  #include "conntrack-tp.h"
>>> @@ -30,6 +34,7 @@
>>>  #include "csum.h"
>>>  #include "ct-dpif.h"
>>>  #include "dp-packet.h"
>>> +#include "entropy.h"
>>>  #include "flow.h"
>>>  #include "netdev.h"
>>>  #include "odp-netlink.h"
>>> @@ -47,6 +52,45 @@
>>>
>>>  VLOG_DEFINE_THIS_MODULE(conntrack);
>>>
>>> +/* Counts calls to nat_random_uint32() that could not obtain any
>>> + * cryptographic randomness at all (neither OpenSSL nor the system
>>> + * entropy pool).  Each occurrence corresponds to one failed NAT tuple
>>> + * allocation attempt. */
>>> +COVERAGE_DEFINE(conntrack_entropy_failed);
>>> +
>>> +/* NAT tuple selection (both the address/port hash basis and the
>>> + * fully-random port paths) must not be predictable from an observed
>>> + * random_uint32() output, so it draws from a cryptographic source here
>>> + * instead of the general-purpose xorshift32 PRNG in random.c.
>>> + *
>>> + * When compiled against OpenSSL 1.1.0+, this uses OpenSSL's PRNG.  If
>>> + * that is unavailable, or RAND_status() reports that it is not properly
>>> + * seeded (see the same check in lib/stream-ssl.c), it falls back to the
>>> + * system entropy pool.  If neither source can provide randomness, this
>>> + * returns false rather than falling back to the non-cryptographic PRNG:
>>> + * callers must fail the NAT tuple allocation (and, transitively, the
>>> + * connection attempt) instead of silently downgrading its quality. */
>>> +static bool
>>> +nat_random_uint32(uint32_t *r)
>>> +{
>>> +#ifdef HAVE_OPENSSL
>>> +    if (RAND_bytes((uint8_t *) r, sizeof *r) == 1 && RAND_status()) {
>>
>> No idea how expensive this is, and/or if it takes systemcalls.
>> This is used in the fast path, so maybe we should do some benchmarking?
>
> AFAICT, RAND_bytes doesn't take system calls unless the prng isn't
> seeded properly.
>
>>> +        return true;
>>> +    }
>>> +
>>> +    static struct vlog_rate_limit rl1 = VLOG_RATE_LIMIT_INIT(1, 5);
>>> +    VLOG_WARN_RL(&rl1, "RAND_bytes unreliable, falling back to system "
>>> +                 "entropy pool for NAT tuple selection");
>>> +#endif
>>> +
>>> +    if (!get_entropy(r, sizeof *r)) {
>>
>> The fallback is expensive; it does open/read/close() on /dev/urandom.
>
> Yes - OTOH, it is considered a security issue otherwise.  Not sure how
> best to deal with not having RAND_bytes() available.  We shouldn't try
> to implement our own CSPRNG (given we already support linking to
> openssl).  Should we not support NAT in this case?  Maybe set a flag
> that we are running in an 'insecure' configuration?

Not sure if we have any people not linking openssl. It's definitely a behaviour 
change.

>>> +        return true;
>>> +    }
>>> +
>>> +    COVERAGE_INC(conntrack_entropy_failed);
>>> +    return false;
>>> +}
>>> +
>>>  COVERAGE_DEFINE(conntrack_full);
>>>  COVERAGE_DEFINE(conntrack_l3csum_checked);
>>>  COVERAGE_DEFINE(conntrack_l3csum_err);
>>> @@ -251,8 +295,16 @@ conntrack_init(void)
>>>
>>>      /* This value can be used during init (e.g. timeout_policy_init()),
>>>       * set it first to ensure it is available.
>>> -     */
>>> -    ct->hash_basis = random_uint32();
>>> +     *
>>> +     * It is also the basis that nat_range_hash() mixes into every NAT
>>> +     * address and (non-random) NAT port choice, so it must come from
>>> +     * nat_random_uint32() rather than the predictable general-purpose
>>> +     * PRNG--otherwise all NAT tuples derived from it are only as
>>
>> The -- looks odd here, maybe an AI text cut/paste. Guess a '; ' would be 
>> better here.
>
> Yes, I had AI writing a test case (and it wrote some comments).  The
> test case stopped making sense (because it could take a long time to
> converge on the xorshift reversal).
>
>>> +     * unpredictable as that single 32-bit xorshift output. */
>>
>> Also, to a non-native speaker, the 'unpredictable' reads odd. Maybe 
>> something like;
>>
>>  * PRNG; otherwise, all NAT tuples derived from it would be
>>  * predictable from that PRNG output.
>
> Sure, I can change it.
>
>>> +    if (!nat_random_uint32(&ct->hash_basis)) {
>>> +        VLOG_FATAL("conntrack: unable to obtain cryptographic randomness "
>>> +                   "to initialize the NAT hash basis");
>>
>> Are we ok with not having a backup?
>
> It's a good question, see above.

Maybe we should just log it (like with the fallback above) and continue?

>>> +    }
>>>
>>>      ovs_rwlock_init(&ct->resources_lock);
>>>      ovs_rwlock_wrlock(&ct->resources_lock);
>>> @@ -2556,8 +2608,19 @@ another_round:
>>>      }
>>>
>>>      if (attempts < range && attempts >= 16) {
>>> +        uint32_t r;
>>> +
>>> +        if (!nat_random_uint32(&r)) {
>>> +            /* CPRNG wasn't available, return false in this case.  It is
>>> +             * possible that the entropy pool is only temporarily 
>>> unaviable,
>>> +             * but bailing on this connection attempt should be okay since
>>> +             * since we don't want to waste cpu cycles for an event that 
>>> may
>>> +             * take a while. */
>>> +            return false;
>>> +        }
>>> +
>>>          attempts /= 2;
>>> -        curr = min + (random_uint32() % range);
>>> +        curr = min + (r % range);
>>>          goto another_round;
>>>      }
>>>
>>> @@ -2613,7 +2676,12 @@ nat_get_unique_tuple(struct conntrack *ct, struct 
>>> conn *conn,
>>>      hash = nat_range_hash(fwd_key, basis, nat_info);
>>>
>>>      if (nat_info->nat_flags & NAT_RANGE_RANDOM) {
>>> -        port_off = random_uint32();
>>> +        if (!nat_random_uint32(&port_off)) {
>>> +            /* The entropy failure here will reflect that we're resource
>>> +             * exhausted.  It is a bit confusing because we're out of
>>> +             * entropy rather than out of actual NAT range. */
>>> +            return false;
>>> +        }
>>>      } else if (basis) {
>>>          port_off = hash;
>>>      } else {

PS: I was playing with some local AI and it complained about the below.
To be honest I did not look into them:

[ ... ]

> @@ -2556,8 +2608,19 @@ another_round:
>      }
>
>      if (attempts < range && attempts >= 16) {
> +        uint32_t r;
> +
> +        if (!nat_random_uint32(&r)) {
> +            /* CPRNG wasn't available, return false in this case.  It is
> +             * possible that the entropy pool is only temporarily unaviable,
> +             * but bailing on this connection attempt should be okay since
> +             * since we don't want to waste cpu cycles for an event that may
> +             * take a while. */
> +            return false;
> +        }

When nat_get_unique_l4() reaches this block, it has already executed
at least one iteration of the FOR_EACH_PORT_IN_RANGE loop above,
which writes intermediate port values into *port (and *peer_port):

>          *port = htons(curr);
>          if (peer_port) {
>              *peer_port = htons(curr);
>          }

If nat_random_uint32() fails, the early return leaves *port (and
*peer_port) set to whichever value the last loop iteration wrote.
The normal exhaustion path, reached when attempts >= range or
attempts < 16, restores the originals:

>      *port = htons(orig);
>      if (peer_port) {
>          *peer_port = htons(orig);
>      }
>      return false;

The early return skips this restore.  In nat_get_unique_tuple(), the
first nat_get_unique_l4() call passes &rev_key->src.port:

>      if (nat_info->nat_action & NAT_ACTION_DST_PORT) {
>          found = nat_get_unique_l4(ct, rev_key, &rev_key->src.port,
>                                    NULL, curr_dport, min_dport, max_dport);
>      }
>
>      if (!found) {
>          found = nat_get_unique_l4(ct, rev_key, &rev_key->dst.port,
>              [...]
>                                    curr_sport, min_sport, max_sport);
>      }

If that call returns false via the entropy early-return after the loop
has modified rev_key->src.port, the second call proceeds with a stale
rev_key->src.port value in its conn_lookup().  Does this leave
rev_key->src.port in an intermediate, unintended state that corrupts
the conn_lookup() in the second nat_get_unique_l4() call?

The triggering condition (OpenSSL and /dev/urandom both failing after
at least one loop iteration but only when range > max_attempts and
initial attempts >= 16) is highly unlikely in practice, but the fix
is straightforward: restore *port (and *peer_port) to htons(orig)
before the early return, matching the existing exhaustion path.

---

Typo in the comment above: "unaviable" should be "unavailable".

Duplicated word in the same comment: "since since".

---

> +#ifdef HAVE_OPENSSL
> +    if (RAND_bytes((uint8_t *) r, sizeof *r) == 1 && RAND_status()) {
> +        return true;
> +    }

The comment above nat_random_uint32() describes this as "the same
check already used in lib/stream-ssl.c", but stream-ssl.c checks
RAND_status() *before* any RAND_bytes() call and uses it to decide
whether to reseed, which is a meaningfully different pattern.  Here,
RAND_status() is checked after a successful RAND_bytes() return.  In
OpenSSL 1.1.0+, RAND_bytes() returning 1 already means the output is
cryptographically strong; a post-call RAND_status() == 0 after that
would be an unusual state.  Could the comment be updated to describe
what scenario this is actually guarding against, to avoid confusion
with the stream-ssl.c pattern?

_______________________________________________
dev mailing list
[email protected]
https://mail.openvswitch.org/mailman/listinfo/ovs-dev

Reply via email to