Hello, Kevin Stewart here. A while back someone asked an interesting question in the DevCentral forum about selecting a client SSL profile based on the device (ex. iOS, Android, Windows Phone). Normally you'd use a browser User-Agent HTTP header to identify the client user agent, but in this case, and based on the OSI model, you wouldn't be able to select an SSL profile (OSI layer 6) based on a User-Agent HTTP header (OSI layer 7), because at this point in time you don't yet have the layer 7 data - it's still encrypted. You could, however, use layer 3 or 4 data (IPs and ports), but that's generally not useful for identifying the client user agent. But there might still be a way...

Lee Brotherston has discovered that during an SSL handshake, most client user agents (different browsers, Dropbox, Skype, etc.) will initiate an SSL handshake request in an ever-so-unique way. The ordered combination of TLS version, record TLS version, ciphersuites, compression options, list of extensions, elliptic curves and signature algorithms are all specific enough that you can actually build a signature based on that data, and the collection of signatures into a database. From that discovery Lee created a project called "tls-fingerprinting". Please check it out. Now certainly, a client's ClientHello could be modified to support different ciphersuites and other features, the same way you could spoof a User-Agent HTTP header. However this modification will often lower security by re-introducing previously unsupported options, or in many cases modification to the user agent's SSL parameters isn't easy or isn't possible.

So given that we now have a new way to identify a user agent based on the client's ClientHello (the first message in the SSL handshake), I decided to re-visit the original DC forum request by integrating Lee's database into an iRules-based solution. The code example in the aforementioned thread just used the client's ciphersuite list, however, so today I'm going to expand on that and use all of the paramaters from the tls-fingerprinting database. Before we get started, I should mention two things:

  1. This article is going to be long (sorry about that), and
  2. We're going to break it down into a few phases, specifically
    • Defining the values in the tls-fingerprinting signature
    • Exporting and converting the tls-fingerprinting database to a BIG-IP external data group
    • Creating a fingerprintTLS PROC iRule
    • Creating the caller iRule

So let's get started.


Defining the values in the tls-fingerprinting signature

Here's an example signature entry in Lee's tls-fingerprinting database (JSON version):

{"id": 0, "desc": "ThunderBird (v38.0.1 OS X)",  "record_tls_version": "0x0301", "tls_version": "0x0303",  "ciphersuite_length": "0x0016",  "ciphersuite": "0xC02B 0xC02F 0xC00A 0xC009 0xC013 0xC014 0x0033 0x0039 0x002F 0x0035 0x000A",  "compression_length": "1",  "compression": "0x00",  "extensions": "0x0000 0xFF01 0x000A 0x000B 0x0023 0x0005 0x000D 0x0015" , "e_curves": "0x0017 0x0018 0x0019" , "sig_alg": "0x0401 0x0501 0x0201 0x0403 0x0503 0x0203 0x0402 0x0202" , "ec_point_fmt": "0x00" }

Broken down it looks like this:

    "id": 0, 
    "desc": "ThunderBird (v38.0.1 OS X)",  
    "record_tls_version": "0x0301", 
    "tls_version": "0x0303",  
    "ciphersuite_length": "0x0016",  
    "ciphersuite": "0xC02B 0xC02F 0xC00A 0xC009 0xC013 0xC014 0x0033 0x0039 0x002F 0x0035 0x000A",  
    "compression_length": "1",  
    "compression": "0x00",  
    "extensions": "0x0000 0xFF01 0x000A 0x000B 0x0023 0x0005 0x000D 0x0015" , 
    "e_curves": "0x0017 0x0018 0x0019" , 
    "sig_alg": "0x0401 0x0501 0x0201 0x0403 0x0503 0x0203 0x0402 0x0202" , 
    "ec_point_fmt": "0x00" 

This is a pretty straight forward set of JSON key-value pairs. And if you're curious about what any of these values mean, I urge you to fire up Wireshark, open a browser to some HTTPS site, and then find a ClientHello message in the capture. You'll see all of these values and more, except for the first two, in that message. Our job then is to a) export the set of signatures to a BIG-IP external data group, and b) create an iRule that extracts all of these values from the client's ClientHello and compares those to the set of signatures in the data group. Of course iRules don't natively support JSON parsing, and while yes I could use iRulesLX for this, I decided to simply reformat the signatures in the data group to something more condusive to TCL iRules.

Exporting and converting the tls-fingerprinting database to a BIG-IP external data group

I don't really care about the "id" value, so I'll leave that out. And the "desc" field will be the value in the data group. The key will be the concatenation of all of the remaining fields.

"signature_data" := "ThunderBird (v38.0.1 OS X)",

I'm also going to remove the "0x" from the hex values, remove whitespace, and delimit each field with the plus (+) sign, so the resulting key for the above signature will look like this:


If any of the values don't exist (ex. the signature doesn't have a sig_alg value), that value is replaced with "@@@@" in the resulting key.

Okay, so to convert the tls-fingerprinting database to a BIG-IP external data group, you have to:

1. Download it - the current JSON-based database is here: https://github.com/LeeBrotherston/tls-fingerprinting/blob/master/fingerprints/fingerprints.json. Copy that JSON data in whole to a local text file. The conversion script uses BASH, so you need a Linux or Mac box. I named the file 'fingerprint.db'.

2. Convert it - use the following BASH script to extract each of the fields from each of the signatures. I should warn you now that my sed/awk/grep foo isn't strong, so I borrowed a BASH JSON parser from here: https://gist.github.com/cjus/1047794


function jsonval () {
    ## description
    desc=`echo $1 | sed 's/\\\\\//\//g' | sed 's/[{}]//g' | awk -v k="text" '{n=split($0,a,","); for (i=1; i<=n; i++) print a[i]}' | sed 's/\"\:\"/\|/g' | sed 's/[\,]/ /g' | sed 's/\"//g' | grep -w "desc" |awk -F": " '{print $2}'`

    ## record_tls_version
    rect=`echo $1 | sed 's/\\\\\//\//g' | sed 's/[{}]//g' | awk -v k="text" '{n=split($0,a,","); for (i=1; i<=n; i++) print a[i]}' | sed 's/\"\:\"/\|/g' | sed 's/[\,]/ /g' | sed 's/\"//g' | grep -w "record_tls_version" |awk -F": " '{print $2}' |sed 's/0x//g'`
    if [ -z "$rect" ]; then rect="@@@@"; fi

    ## tls_version
    tlsv=`echo $1 | sed 's/\\\\\//\//g' | sed 's/[{}]//g' | awk -v k="text" '{n=split($0,a,","); for (i=1; i<=n; i++) print a[i]}' | sed 's/\"\:\"/\|/g' | sed 's/[\,]/ /g' | sed 's/\"//g' | grep -w "tls_version" |awk -F": " '{print $2}' |sed 's/0x//g'`
    if [ -z "$tlsv" ]; then tlsv="@@@@"; fi

    ## ciphersuite_length
    cipl=`echo $1 | sed 's/\\\\\//\//g' | sed 's/[{}]//g' | awk -v k="text" '{n=split($0,a,","); for (i=1; i<=n; i++) print a[i]}' | sed 's/\"\:\"/\|/g' | sed 's/[\,]/ /g' | sed 's/\"//g' | grep -w "ciphersuite_length" |awk -F": " '{print $2}' |sed 's/0x//g' |sed 's/ //g'`
    if [ -z "$cipl" ]; then cipl="@@@@"; fi

    ## ciphersuite
    ciph=`echo $1 | sed 's/\\\\\//\//g' | sed 's/[{}]//g' | awk -v k="text" '{n=split($0,a,","); for (i=1; i<=n; i++) print a[i]}' | sed 's/\"\:\"/\|/g' | sed 's/[\,]/ /g' | sed 's/\"//g' | grep -w "ciphersuite" |awk -F": " '{print $2}' |sed 's/0x//g' |sed 's/ //g'`
    if [ -z "$ciph" ]; then tlsv="ciph"; fi

    ## compression_length
    coml=`echo $1 | sed 's/\\\\\//\//g' | sed 's/[{}]//g' | awk -v k="text" '{n=split($0,a,","); for (i=1; i<=n; i++) print a[i]}' | sed 's/\"\:\"/\|/g' | sed 's/[\,]/ /g' | sed 's/\"//g' | grep -w "compression_length" |awk -F": " '{print $2}'`
    if [ -z "$coml" ]; then coml="@@@@"; fi

    ## compression
    comp=`echo $1 | sed 's/\\\\\//\//g' | sed 's/[{}]//g' | awk -v k="text" '{n=split($0,a,","); for (i=1; i<=n; i++) print a[i]}' | sed 's/\"\:\"/\|/g' | sed 's/[\,]/ /g' | sed 's/\"//g' | grep -w "compression" |awk -F": " '{print $2}' |sed 's/0x//g' |sed 's/ //g'`
    if [ -z "$comp" ]; then comp="@@@@"; fi

    ## extensions
    exte=`echo $1 | sed 's/\\\\\//\//g' | sed 's/[{}]//g' | awk -v k="text" '{n=split($0,a,","); for (i=1; i<=n; i++) print a[i]}' | sed 's/\"\:\"/\|/g' | sed 's/[\,]/ /g' | sed 's/\"//g' | grep -w "extensions" |awk -F": " '{print $2}' |sed 's/0x//g' |sed 's/ //g'`
    if [ -z "$exte" ]; then exte="@@@@"; fi

    ## e_curves
    ecur=`echo $1 | sed 's/\\\\\//\//g' | sed 's/[{}]//g' | awk -v k="text" '{n=split($0,a,","); for (i=1; i<=n; i++) print a[i]}' | sed 's/\"\:\"/\|/g' | sed 's/[\,]/ /g' | sed 's/\"//g' | grep -w "e_curves" |awk -F": " '{print $2}' |sed 's/0x//g' |sed 's/ //g'`
    if [ -z "$ecur" ]; then ecur="@@@@"; fi

    ## sig_alg
    siga=`echo $1 | sed 's/\\\\\//\//g' | sed 's/[{}]//g' | awk -v k="text" '{n=split($0,a,","); for (i=1; i<=n; i++) print a[i]}' | sed 's/\"\:\"/\|/g' | sed 's/[\,]/ /g' | sed 's/\"//g' | grep -w "sig_alg" |awk -F": " '{print $2}' |sed 's/0x//g' |sed 's/ //g'`
    if [ -z "$siga" ]; then siga="@@@@"; fi

    ## ec_point_fmt
    ecfp=`echo $1 | sed 's/\\\\\//\//g' | sed 's/[{}]//g' | awk -v k="text" '{n=split($0,a,","); for (i=1; i<=n; i++) print a[i]}' | sed 's/\"\:\"/\|/g' | sed 's/[\,]/ /g' | sed 's/\"//g' | grep -w "ec_point_fmt" |awk -F": " '{print $2}' |sed 's/0x//g' |sed 's/ //g'`
    if [ -z "$ecfp" ]; then ecfp="@@@@"; fi

    echo "\"$rect+$tlsv+$cipl+$ciph+$coml+$comp+$exte+$ecur+$siga+$ecfp\" := \"$desc\","


for i in `cat fingerprint.db`; do
    jsonval $i

Create this BASH script however you like (VI, VIM, Joe, Nano, whatever), save it, chmod it so that it'll execute ('chmod 755 parser.sh'), and then run it ('./parser.sh'). It'll just echo the reformatted signatures to the screen, so you'll want to capture that as a file ('./parser.sh > fingerprint.dg'). It's also be a little slow, again due to my aggregious lack of sed/awk/grep (and regex) foo, but it should still finish in less than a minute.

3. Import it - now go to the BIG-IP management UI, and under System - File Management - Data Group File List, click Import. Choose your reformatted text file, give it a meaningful name (ex. fingerprint_db), select "String" as the File Contents type, and use the same name (ex. fingerprint_db) in the Data Group Name field. On a v12 BIG-IP this will auto-create the local data group. On earlier systems you'll need to go manually create the local data group object that points to this external data group.


Creating a fingerprintTLS PROC iRule

So now that we've reformatted and imported the fingerprintTLS database, let's build the iRule to parse out the data from the client's ClientHello. I should also warn you that this process requires a lot of binary manipulation, so please don't try to ingest it all at once if you're new to iRules. I'm building this iRule as a separate PROC that other data plane iRules can call. It won't be directly attached to a virtual server.

## Library-Rule

## TLS Fingerprint Procedure #################
## Author: Kevin Stewart, 12/2016
## Derived from Lee Brotherston's "tls-fingerprinting" project @ https://github.com/LeeBrotherston/tls-fingerprinting
## Purpose: to identify the user agent based on unique characteristics of the TLS ClientHello message
## Input: 
##      Full TCP payload collected in CLIENT_DATA event of a TLS handshake ClientHello message
##      Record length (rlen)
##      TLS outer version (outer)
##      TLS inner version (inner)
##      Client IP
##      Server IP
proc fingerprintTLS { payload rlen outer inner clientip serverip } {
    ## The first 43 bytes of a ClientHello message are the record type, TLS versions, some length values and the
    ## handshake type. We should already know this stuff from the calling iRule. We're also going to be walking the
    ## packet, so the field_offset variable will be used to track where we are.
    set field_offset 43

    ## The first value in the payload after the offset is the session ID, which may be empty. Grab the session ID length
    ## value and move the field_offset variable that many bytes forward to skip it.
    binary scan ${payload} @${field_offset}c sessID_len
    set field_offset [expr {${field_offset} + 1 + ${sessID_len}}]

    ## The next value in the payload is the ciphersuite list length (how big the ciphersuite list is. We need the binary
    ## and hex values of this data.
    binary scan ${payload} @${field_offset}S cipherList_len
    binary scan ${payload} @${field_offset}H4 cipherList_len_hex
    set cipherList_len_hex_text ${cipherList_len_hex}

    ## Now that we have the ciphersuite list length, let's offset the field_offset variable to skip over the length (2) bytes
    ## and go get the ciphersuite list. Multiple by 2 to get the number of appropriate hex characters.
    set field_offset [expr {${field_offset} + 2}]
    set cipherList_len_hex [expr {${cipherList_len} * 2}]
    binary scan ${payload} @${field_offset}H${cipherList_len_hex} cipherlist
    ## Next is the compression method length and compression method. First move field_offset to skip past the ciphersuite
    ## list, then grab the compression method length. Then move field_offset past the length (2) bytes and grab the 
    ## compression method value. Finally, move field_offset past the compression method bytes.
    set field_offset [expr {${field_offset} + ${cipherList_len}}]
    binary scan ${payload} @${field_offset}c compression_len
    #set field_offset [expr {${field_offset} + ${compression_len}}]
    set field_offset [expr {${field_offset} + 1}]
    binary scan ${payload} @${field_offset}H[expr {${compression_len} * 2}] compression_type
    set field_offset [expr {${field_offset} + ${compression_len}}]
    ## We should be in the extensions section now, so we're going to just run through the remaining data and
    ## pick out the extensions as we go. But first let's make sure there's more record data left, based on 
    ## the current field_offset vs. rlen.
    if { [expr {${field_offset} < ${rlen}}] } {
        ## There's extension data, so let's go get it. Skip the first 2 bytes that are the extensions length
        set field_offset [expr {${field_offset} + 2}]
        ## Make a variable to store the extension types we find
        set extensions_list ""
        ## Pad rlen by 1 byte
        set rlen [expr ${rlen} + 1]
        while { [expr {${field_offset} <= ${rlen}}] } {
            ## Grab the first 2 bytes to determine the extension type
            binary scan ${payload} @${field_offset}H4 ext

            ## Store the extension in the extensions_list variable
            append extensions_list ${ext}
            ## Increment field_offset past the 2 bytes of the extension type
            set field_offset [expr {${field_offset} + 2}]
            ## Grab the 2 bytes of extension lenth
            binary scan ${payload} @${field_offset}S ext_len
            ## Increment field_offset past the 2 bytes of the extension length
            set field_offset [expr {${field_offset} + 2}]
            ## Look for specific extension types in case these need to increment the field_offset (and because we need their values)
            switch $ext {
                "000b" {
                    ## ec_point_format - there's another 1 byte after length
                    ## Grab the extension data
                    binary scan ${payload} @[expr {${field_offset} + 1}]H[expr {(${ext_len} - 1) * 2}] ext_data
                    set ec_point_format ${ext_data}
                "000a" {
                    ## elliptic_curves - there's another 2 bytes after length
                    ## Grab the extension data
                    binary scan ${payload} @[expr {${field_offset} + 2}]H[expr {(${ext_len} - 2) * 2}] ext_data
                    set elliptic_curves ${ext_data}
                "000d" {
                    ## sig_alg - there's another 2 bytes after length
                    ## Grab the extension data
                    binary scan ${payload} @[expr {${field_offset} + 2}]H[expr {(${ext_len} - 2) * 2}] ext_data
                    set sig_alg ${ext_data}
                default {
                    ## Grab the otherwise unknown extension data
                    binary scan ${payload} @${field_offset}H[expr {${ext_len} * 2}] ext_data
            ## Increment the field_offset past the extension data length. Repeat this loop until we reach rlen (the end of the payload)
            set field_offset [expr {${field_offset} + ${ext_len}}]
    ## Now let's compile all of that data.
    set cipl [string toupper ${cipherList_len_hex_text}]
    set ciph [string toupper ${cipherlist}]
    set coml ${compression_len}
    set comp [string toupper ${compression_type}]
    if { ( [info exists extensions_list] ) and ( ${extensions_list} ne "" ) } { set exte [string toupper ${extensions_list}] } else { set exte "@@@@" }
    if { ( [info exists elliptic_curves] ) and ( ${elliptic_curves} ne "" ) } { set ecur [string toupper ${elliptic_curves}] } else { set ecur "@@@@" }
    if { ( [info exists sig_alg] ) and ( ${sig_alg} ne "" ) } { set siga [string toupper ${sig_alg}] } else { set siga "@@@@" }
    if { ( [info exists ec_point_format] ) and ( ${ec_point_format} ne "" ) } { set ecfp [string toupper ${ec_point_format}] } else { set ecfp "@@@@" }

    ## Initialize the match variable
    set match ""
    ## Now let's build the fingerprint string and search the database
    set fingerprint_str "${outer}+${inner}+${cipl}+${ciph}+${coml}+${comp}+${exte}+${ecur}+${siga}+${ecfp}"
    ## Un-comment this line to display the fingerprint string in the LTM log for troubleshooting
    ## log local0. "${clientip}-${serverip}: fingerprint_str = ${fingerprint_str}"

    if { [class match ${fingerprint_str} equals fingerprint_db] } {
        ## Direct match
        set match [class match -value ${fingerprint_str} equals fingerprint_db]
    } elseif { not ( ${ciph} starts_with "C0" ) and not ( ${ciph} starts_with "00" ) } {
        ## Hmm.. there's no direct match, which could either mean a database entry doesn't exist, or Chrome (and Opera) are adding
        ## special values to the cipherlist, extensions list and elliptic curves list.
        ##  ex. 9A9A, 5A5A, EAEA, BABA, etc. at the beginning of the cipherlist 
        ## Let's strip out these anomalous values and try the match again.
        ## Substract 2 bytes from cipherlist length
        set cipl [format %04x [expr [expr 0x${cipl}] - 2]]

        ## Subtract 2 bytes from the front of the cipher list
        set ciph [string range ${ciph} 4 end]
        ## Subtract 2 bytes from the front of the extensions list
        set exte [string range ${exte} 4 end]
        ## There might be an additional random set in the string that needs to be removed (pattern is "(.)A\1A")
        regsub {(.)A\1A} ${exte} "" exte

        ## Subtract 2 bytes from the front of the elliptic curves list
        set ecur [string range ${ecur} 4 end]
        ## Rebuild the fingerprint string
        set fingerprint_str "${outer}+${inner}+${cipl}+${ciph}+${coml}+${comp}+${exte}+${ecur}+${siga}+${ecfp}"

        if { [class match ${fingerprint_str} equals fingerprint_db] } {
            ## Guess match
            set match [class match -value ${fingerprint_str} equals fingerprint_db]
        } else {
            ## No match
            set match ""
    ## Return the matching user agent string
    return ${match}

The PROC requires as input the full TCP payload (of the ClientHello message), the record length (extracted from the ClientHello message), the "outer" record TLS version and "inner" TLS version (also extracted form the ClientHello message). Using these values the PROC basically walks the payload looking for each of the required values (ciphersuite length, ciphersuite list, compression length, compression list, extensions list, elliptic curves, signature algorithms, and ec point formats). If any value doesn't exist in the payload (ex. the ClientHello doesn't contain a Sig_Alg field), that value is replaced with "@@@@". Once all of the values are found, the fingerprint string is created and used to search the data group. If there's a match, the user agent string (ex. ThunderBird (v38.0.1 OS X)) is returned to the caller. While testing this I noticed that newer versions of Chrome and Opera added what looked like "markers" to the ciphersuite list, extensions list, and elliptic curves list (ex. 9A9A, 5A5A, EAEA, BABA - always some alphanumeric value, followed by 'A', and repeated.). A cursory search didn't explain what these are, so maybe someone will know and report back. In the meantime, I added a "guess" function that removed these markers and tried the data group search again. All of the desktop browser testing (including Chrome and Opera) did get an accurate match with either the direct or guessed fingerprint, so I'll leave that in there until I find a better way to handle the markers.


Creating the caller iRule

The only thing left to do is to create the caller iRule. This iRule only needs to detect an SSL/TLS ClientHello, and then pass that to the fingerprint PROC. This is just a stub iRule to show the proper implementation. Once you've determined a TCP packet is an SSL/TLS handshake ClientHello, call the PROC and then do something useful with the resulting user agent string, like switch the client SSL profile.

    ## Collect the TCP payload
    ## Get the TLS packet type and versions
    if { ! [info exists rlen] } {
        binary scan [TCP::payload] cH4ScH6H4 rtype outer_sslver rlen hs_type rilen inner_sslver
        if { ( ${rtype} == 22 ) and ( ${hs_type} == 1 ) } {
            ## This is a TLS ClientHello message (22 = TLS handshake, 1 = ClientHello)
            ## Call the fingerprintTLS proc
            set fingerprint [call Library-Rule::fingerprintTLS [TCP::payload] ${rlen} ${outer_sslver} ${inner_sslver} [IP::client_addr] [IP::local_addr]]
			### Do Something here ###
            log local0. "match = ${fingerprint}"
			### Do Something here ###
    # Collect the rest of the record if necessary
    if { [TCP::payload length] < $rlen } {
        TCP::collect $rlen
    ## Release the paylaod


What happens if there's no match?

Yes, there are some caveats...

It's safe to say that the tls-fingerprinting database isn't all inclusive. In fact it's FAR FROM COMPLETE and not always exact. I found, for example, that my version of Dropbox on a Win7 box (v16.4.30) doesn't make a match. It's nearly impossible to have the signature for every unique user agent every created, and all of the variations and versions of that agent. But what the database does have is the signatures for most browsers, so at the very least it makes for a nice way to whitelist browsers (vs. other agents). It also doesn't technically resolve the question in the original DC forum thread. The question was how to identify the device (ie. iOS, Android, Windows Phone), and for that you'd need some specific agent loaded on the device (not a browser) that could report that information. Mobile device management (MDM) solutions are particularly good at that sort of thing. The browser, Dropbox or other user agent on the mobile device may not specifically report the device (ex. "for iOS"). Some do, but I've found that most don't. At the end of this aticle I've included a few signatures that I found in my testing that aren't in the database. If your curious, uncomment the log local0. "${clientip}-${serverip}: fingerprint_str = ${fingerprint_str}" line in the fingerprintTLS PROC and then tail the LTM log. The caller iRule is already logging the returned user agent string, so if that is empty, you'll see the empty match in the log (match = ), preceded by the unmatched signature.

Where this project may be most useful is in outbound traffic management, where you want to decrypt and inspect the Internet-bound traffic, but cannot decrypt some user agents becuase of things like cerificate pinning. Since the pinning decision happens at the client, the only other recourse is to bypass decryption and inspection based on the destination host name or IP address, which can be a tedious thing to manage. TLS fingerprinting might allow you to simply decrypt and inspect for the user agents that you know aren't affected by pinning, specifically browsers. You'll potentially miss some things that you could have decrypted, but you'll save yourself the burden of managing an ever-growing list of pinner exclusions.

And on a final note, binary iRule manipulation is a very CPU-intensive thing to do. I could have very simply converted the raw payload to one long hex string (once) and walked that with string tools. I'll update the code when I have some time.

- Kevin




Additional Signatures
"0301+0303+0028+C02BC02F009ECC14CC13C00AC009C013C014C007C011003300320039009C002F0035000A00050004+1+00+0000FF01000A000B0023755000050012000D+001700180019+04010501020104030503020304020202+00" := "Dropbox",
"0301+0303+0028+C02BC02F009ECC14CC13C00AC009C013C014C007C011003300320039009C002F0035000A00050004+1+00+0000FF01000A000B002300050012000D+001700180019+04010501020104030503020304020202+00" := "Dropbox",
"0301+0303+001A+C030C028C014C02FC027C013009F006B0039009E0067003300FF+1+00+0000000B000A0023000D+00170019001C001B0018001A0016000E000D000B000C0009000A+060106020603050105020503040104020403030103020303020102020203+000102" := "Dropbox",
"0301+0303+0094+C030C02CC032C02EC02FC02BC031C02D00A500A300A1009F00A400A200A0009EC028C024C014C00AC02AC026C00FC005006B006A006900680039003800370036C027C023C013C009C029C025C00EC00400670040003F003E0033003200310030C012C008C00DC00300880087008600850045004400430042001600130010000D009D009C003D0035003C002F00840041000A00FF+1+00+000B000A0023000D0015+00170019001C001B0018001A0016000E000D000B000C0009000A+060106020603050105020503040104020403030103020303020102020203+000102" := "Dropbox",
"0301+0303+0028+C02BC02CC02FC030009E009FC009C00AC013C01400330039C007C011009C009D002F0035000500FF+1+00+0000000B000A0023000D+000E000D0019000B000C00180009000A00160017000800060007001400150004000500120013000100020003000F00100011+060106020603050105020503040104020403030103020303020102020203+000102" := "Android Google API Access",
"0301+0303+001E+CC14CC13C02BC02F009EC00AC0140039C009C0130033009C0035002F000A+1+00+FF01000000170023000D0005337400120010000B000A+00170018+0601060305010503040104030301030302010203+00" := "Chrome 47.0.2526.83",
"0301+0303+001E+CC14CC13C02BC02F009EC00AC0140039C009C0130033009C0035002F000A+1+00+FF01000000170023000D0005337400120010000B000A+00170018+0601060305010503040104030301030302010203+00" := "Chrome 48.0.2564.97",
"0301+0303+001E+CC14CC13C02BC02F009EC00AC0140039C009C0130033009C0035002F000A+1+00+FF01000000170023000D00053374001200107550000B000A0015+00170018+0601060305010503040104030301030302010203+00" := "Chrome 48.0.2564.97",
"0301+0303+0022+CCA9CCA8CC14CC13C02BC02FC02CC030C009C013C00AC014009C009D002F0035000A+1+00+FF01000000170023000D0005001200107550000B000A0018+001D00170018+06010603050105030401040302010203+00" := "Android Silk Browser",
"0301+0303+0022+CCA9CCA8CC14CC13C02BC02FC02CC030C009C013C00AC014009C009D002F0035000A+1+00+FF01000000170023000D0005001200107550000B000A00180015+001D00170018+06010603050105030401040302010203+00" := "Android Silk Browser"
"0303+0303+0038+C02CC02BC030C02F009F009EC024C023C028C027C00AC009C014C01300390033009D009C003D003C0035002F000A006A0040003800320013+1+00+0005000A000B000D0023001000175500FF01+001D00170018+040105010201040305030203020206010603+00" := "Internet Explorer 11.447.14393.0(Win 10)",
"0301+0303+0022+C02BC02FC02CC030CCA9CCA8CC14CC13C009C013C00AC014009C009D002F0035000A+1+00+FF0100170023000D0005001200107550000B000A+001D00170018+06010603050105030401040302010203+00" := "Chrome 55.0.2883.87",