Forum Discussion

henrik_k's avatar
henrik_k
Icon for Altostratus rankAltostratus
Sep 03, 2024

TCP::collect and large TLS v1.3 client hello packets

Is anyone using iRules successfully to parse SNI names from the new TLS 1.3 hybridized Kyber client hello packets? The problem is the these packets are larger than MTU(?) size, around ~1800 bytes. Normal hello packets are ~500 bytes.

I'm using TCP profile for which iRule parses SNI name to pass the connection as is to correct destination pool.

How to reproduce:

when CLIENT_ACCEPTED {
  TCP::collect
}
when CLIENT_DATA {
# [TCP::payload length] shows only 1352, rest of packet missing, CLIENT_DATA is never called again
}

It only ever gets the first ~1352 bytes from packet, CLIENT_DATA is only called once, seems there is no way to get rest of the packet.

If I add argument to collect length >1500 with "TCP::collect 1600", then it will read the whole ~1800 byte packet in first CLIENT_DATA. But this will break all connections that send normal small ~500 byte hello packets, as it just keeps on waiting for data indefinitely. Is there any workaround? BIG-IP version 16.1.4.1.

 

  • Here is working cleaned up example code for everyone. TLS parsing itself is based on some old forum code which I cleaned up, all credits there.

    when CLIENT_ACCEPTED {
      set collect_count 0
      set tls_servername ""
      TCP::collect
    }
    
    when CLIENT_DATA {
      # Collect max 2 extra packets
      if { $collect_count > 2 } {
        reject; event disable all; return
      }
    
      # Parse first packet?
      if { not $collect_count } {
        if { [binary scan [TCP::payload] cSSc tls_xacttype tls_version tls_recordlen tls_action] != 4 } {
          reject; event disable all; return
        }
        # Only allow TLSv1.2 = 771, TLSv1.3 = 769
        if { $tls_xacttype ne "22" or ($tls_version ne "769" and $tls_version ne "771") or $tls_action ne "1" } {
          reject; event disable all; return
        }
        set record_offset 43
        if { not [binary scan [TCP::payload] @${record_offset}c tls_sessidlen] } {
          reject; event disable all; return
        }
        incr record_offset [expr {1 + $tls_sessidlen}]
        if { not [binary scan [TCP::payload] @${record_offset}S tls_ciphlen] } {
          reject; event disable all; return
        }
        incr record_offset [expr {2 + $tls_ciphlen}]
        if { not [binary scan [TCP::payload] @${record_offset}c tls_complen] } {
          reject; event disable all; return
        }
        incr record_offset [expr {1 + $tls_complen}]
        if { not [binary scan [TCP::payload] @${record_offset}S tls_extenlen] } {
          reject; event disable all; return
        }
        incr record_offset 2
      }
    
      # Collect more if TLS extensions data not fully received
      if { $tls_extenlen > [TCP::payload length] - $record_offset } {
        incr collect_count
        return
      }
    
      if { not [binary scan [TCP::payload] @${record_offset}a* tls_extensions] } {
        reject; event disable all; return
      }
      for { set x 0 } { $x < $tls_extenlen } { incr x 4 } {
        if { [binary scan $tls_extensions @${x}SS etype elen] != 2 } {
          reject; event disable all; return
        }
        if { $etype == "00" } {
          if { [binary scan $tls_extensions @[expr {$x + 9}]A[expr {$elen - 5}] tls_servername] } {
            break
          }
        }
        incr x $elen
      }
    
      # Choose pool, unknown will be rejected
      switch -- [string tolower $tls_servername] {
        "host1.example.com" { pool host1.example.com }
        "host2.example.com" { pool host2.example.com }
        default { reject; event disable all; return }
      }
    
      TCP::release
    }

     

  • It sounds like here that you're trying to read the whole CLIENT_HELLO message (at L4), but sometimes it's bigger than a single (L2) Ethernet packet. The example you mention will only ever grab the very first (L2) packet, so it won't work for this kind of dynamic situation. You'll have to switch to some kind of implementation that runs TCP::collect inside of CLIENT_DATA until you've captured the SNI data you need, then run TCP::release to let the connection proceed. This is a fairly common pattern while dealing with streaming data of unknown length.

     

    Can you share the logic you're using to grab the SNI? We could take a shot at adjusting the rule.

    • henrik_k's avatar
      henrik_k
      Icon for Altostratus rankAltostratus

      Hi, I've already tried to call TCP::collect second time inside CLIENT_DATA with or without length argument, nothing ever happens. CLIENT_DATA is never called again and TCP::payload never changes. Can you provide simple protocode which you think should achieve reading both L2 packets? I believe I've already tried all variations I can think of.

      • henrik_k's avatar
        henrik_k
        Icon for Altostratus rankAltostratus

        Oh dear this was an user error. I didn't see I had some leftover other priority CLIENT_DATA code that died, this is why it never got called again...

        I now managed to get it to collect more data when needed, I'll post a cleaned up code in a moment for others if interested.

  • Here is working cleaned up example code for everyone. TLS parsing itself is based on some old forum code which I cleaned up, all credits there.

    when CLIENT_ACCEPTED {
      set collect_count 0
      set tls_servername ""
      TCP::collect
    }
    
    when CLIENT_DATA {
      # Collect max 2 extra packets
      if { $collect_count > 2 } {
        reject; event disable all; return
      }
    
      # Parse first packet?
      if { not $collect_count } {
        if { [binary scan [TCP::payload] cSSc tls_xacttype tls_version tls_recordlen tls_action] != 4 } {
          reject; event disable all; return
        }
        # Only allow TLSv1.2 = 771, TLSv1.3 = 769
        if { $tls_xacttype ne "22" or ($tls_version ne "769" and $tls_version ne "771") or $tls_action ne "1" } {
          reject; event disable all; return
        }
        set record_offset 43
        if { not [binary scan [TCP::payload] @${record_offset}c tls_sessidlen] } {
          reject; event disable all; return
        }
        incr record_offset [expr {1 + $tls_sessidlen}]
        if { not [binary scan [TCP::payload] @${record_offset}S tls_ciphlen] } {
          reject; event disable all; return
        }
        incr record_offset [expr {2 + $tls_ciphlen}]
        if { not [binary scan [TCP::payload] @${record_offset}c tls_complen] } {
          reject; event disable all; return
        }
        incr record_offset [expr {1 + $tls_complen}]
        if { not [binary scan [TCP::payload] @${record_offset}S tls_extenlen] } {
          reject; event disable all; return
        }
        incr record_offset 2
      }
    
      # Collect more if TLS extensions data not fully received
      if { $tls_extenlen > [TCP::payload length] - $record_offset } {
        incr collect_count
        return
      }
    
      if { not [binary scan [TCP::payload] @${record_offset}a* tls_extensions] } {
        reject; event disable all; return
      }
      for { set x 0 } { $x < $tls_extenlen } { incr x 4 } {
        if { [binary scan $tls_extensions @${x}SS etype elen] != 2 } {
          reject; event disable all; return
        }
        if { $etype == "00" } {
          if { [binary scan $tls_extensions @[expr {$x + 9}]A[expr {$elen - 5}] tls_servername] } {
            break
          }
        }
        incr x $elen
      }
    
      # Choose pool, unknown will be rejected
      switch -- [string tolower $tls_servername] {
        "host1.example.com" { pool host1.example.com }
        "host2.example.com" { pool host2.example.com }
        default { reject; event disable all; return }
      }
    
      TCP::release
    }