cancel
Showing results for 
Search instead for 
Did you mean: 
Login & Join the DevCentral Connects Group to watch the Recorded LiveStream (May 12) on Basic iControl Security - show notes included.

ASM Brute-Force - Common Username

Mark_van_D
Cirrostratus
Cirrostratus

Hi everyone,

I'm trying to come up with a creative solution to an application login problem.  Only identified when we applied Brute-Force protection using Form-Based Auth.

The Login Page has the following fields:

customer_id, username, password

Initially login page properties where set as below:

Mark_van_D_0-1646738458660.png

However it turns out that the usernames are not unique across customer_ids.  For example there are multiple usernames for "mark".  So if one customer_id triggers a captcha for username mark, then all other customer_ids using mark are affected.

So the obvious solution would be to look at creating a concat of customer_id-username (making it unique).

We created the following iRule which would capture the three fields (customer_id, username and password) and concat them into HTTP Basic Auth.

 

when RULE_INIT {
    ## user-defined: size in bytes of HTTP request to parse
    set static::max_req_size 1048576
    ## the names of the tags for the username and password. 
    set static::customerid_tag "customer_id"
    set static::username_tag "username"
    set static::password_tag "password"
    set static::login_url "/user_login.php"
    set static::content_type "application/x-www-form-urlencoded"
}

when HTTP_REQUEST {
    # Content-Type was configured to prevent the collection of Captcha events as well
    if { ([HTTP::method] eq "POST") && ([string tolower [HTTP::uri]] contains $static::login_url ) && ([string tolower [HTTP::header "Content-Type"]] contains $static::content_type )} {
    # Trigger collection for up to max_req_size of data
        set modify_response_content_type 1

    if {[HTTP::header "Content-Length"] ne "" && [HTTP::header "Content-Length"] <= $static::max_req_size } {
        set content_length [HTTP::header "Content-Length"]
        } else {
        set content_length $static::max_req_size
        }
        # Check if $content_length is not set to 0
    if { $content_length > 0} {
        HTTP::collect $content_length
        }
    }
}

when HTTP_REQUEST_DATA {
    # collect the incoming payload
    set payload [HTTP::payload]
    ## skipahead is set to 1 to avoid matching the equals sign
    set raw_customerid [findstr $payload $static::customerid_tag "12" "&"]
    set raw_username [findstr $payload $static::username_tag "9" "&"]
    set raw_password [findstr $payload $static::password_tag "9"]

    set auth_header_b64 [b64encode "$raw_customerid-$raw_username:$raw_password"]
    HTTP::header insert "Authorization" "Basic $auth_header_b64"
    #HTTP::header insert "Basic" "True"
}
when HTTP_REQUEST_RELEASE {
    #Don't pass header to backend
    HTTP::header remove "Authorization"
}

 

We then updated the login page properties to

Mark_van_D_1-1646739283009.png

This seems to partially work, the Authorization header is passed to the F5 and the original payload is left untouched.

The username identified by ASM policy is customer_id-username and any captcha screens are now unique to the new user.  However if a captcha does come up you can enter anything to solve and it will take you back to the login screen (kinda defeating captcha).

I have a feeling that this may be due to the Authorization header and the payload having different usernames.  As the captcha would have been triggered for customer_id-username but the payload the captcha sends through is the untouched username and the captcha support id don't match.

Another thought was to look at using ASM events, but I'm not convinced of that.

Has anyone come across a similar issues?  How did you solve it?

Thanks Mark

1 ACCEPTED SOLUTION

Ok so turns out that part of the problem was with how I was using findstr.

What was happening:

 

set raw_password [findstr $payload $static::password_tag "9"]

 

With the line above under the normal login process with: Content-Type: application/x-www-form-urlencoded

this was correctly grabbing the password from the payload.

But when the CAPTCHA changed this to: Content-Type: multipart/form-data

It was capturing the password including the multipart boundary, therefore changing the Basic Auth header and causing a bunch of issues (not being able to solve the CAPTCHA at all, or in some cases allowing anything to be entered to solve).

So we changed the password capture to break on "\r\n" which seems to have done the trick.

The final iRule we ended up using was:

 

when RULE_INIT {
    ## user-defined: size in bytes of HTTP request to parse
    set static::max_req_size 1048576
    ## the names of the tags for the username and password. 
    set static::customerid_tag "customer_id"
    set static::username_tag "username"
    set static::password_tag "password"
    set static::login_url "/user_login.php"
 }

when HTTP_REQUEST {
    if { ([HTTP::method] eq "POST") && ([string tolower [HTTP::uri]] contains $static::login_url ) } {
    # Trigger collection for up to max_req_size of data
        set modify_response_content_type 1

    if {[HTTP::header "Content-Length"] ne "" && [HTTP::header "Content-Length"] <= $static::max_req_size } {
        set content_length [HTTP::header "Content-Length"]
        } else {
        set content_length $static::max_req_size
        }
        # Check if $content_length is not set to 0
    if { $content_length > 0} {
        HTTP::collect $content_length
        }
    }
}

when HTTP_REQUEST_DATA {
    # collect the incoming payload
    set payload [HTTP::payload]
    ## skipahead is set to fieldname +1 to avoid matching the equals sign.
    set raw_customerid [findstr $payload $static::customerid_tag "12" "&"]
    set raw_username [findstr $payload $static::username_tag "9" "&"]
    set raw_password [findstr $payload $static::password_tag "9" "\r\n"]

    set auth_header_b64 [b64encode "$raw_customerid-$raw_username:$raw_password"]
    HTTP::header insert "Authorization" "Basic $auth_header_b64"
}
when HTTP_REQUEST_RELEASE {
    #Don't pass header to backend
    HTTP::header remove "Authorization"
}

 

Don't forget to mask the values of the Authorization header in the ASM policy.

View solution in original post

2 REPLIES 2

Mark_van_D
Cirrostratus
Cirrostratus

After doing some trouble-shooting found a problem with the iRule.

I had configured the iRule not to capture the CAPTCHA events as that was causing me a problem.  This turned out to be the main issue.

The initial "normal" payload had a

Content-Type: application/x-www-form-urlencoded

But the "CAPTCHA" payload was

Content-Type: multipart/form-data

With this payload the extraction of the password field included the boundary data, so when it created the Authorization header this was different from the original.

Just running through a few more sanity tests and will then upload the updated iRule.

Ok so turns out that part of the problem was with how I was using findstr.

What was happening:

 

set raw_password [findstr $payload $static::password_tag "9"]

 

With the line above under the normal login process with: Content-Type: application/x-www-form-urlencoded

this was correctly grabbing the password from the payload.

But when the CAPTCHA changed this to: Content-Type: multipart/form-data

It was capturing the password including the multipart boundary, therefore changing the Basic Auth header and causing a bunch of issues (not being able to solve the CAPTCHA at all, or in some cases allowing anything to be entered to solve).

So we changed the password capture to break on "\r\n" which seems to have done the trick.

The final iRule we ended up using was:

 

when RULE_INIT {
    ## user-defined: size in bytes of HTTP request to parse
    set static::max_req_size 1048576
    ## the names of the tags for the username and password. 
    set static::customerid_tag "customer_id"
    set static::username_tag "username"
    set static::password_tag "password"
    set static::login_url "/user_login.php"
 }

when HTTP_REQUEST {
    if { ([HTTP::method] eq "POST") && ([string tolower [HTTP::uri]] contains $static::login_url ) } {
    # Trigger collection for up to max_req_size of data
        set modify_response_content_type 1

    if {[HTTP::header "Content-Length"] ne "" && [HTTP::header "Content-Length"] <= $static::max_req_size } {
        set content_length [HTTP::header "Content-Length"]
        } else {
        set content_length $static::max_req_size
        }
        # Check if $content_length is not set to 0
    if { $content_length > 0} {
        HTTP::collect $content_length
        }
    }
}

when HTTP_REQUEST_DATA {
    # collect the incoming payload
    set payload [HTTP::payload]
    ## skipahead is set to fieldname +1 to avoid matching the equals sign.
    set raw_customerid [findstr $payload $static::customerid_tag "12" "&"]
    set raw_username [findstr $payload $static::username_tag "9" "&"]
    set raw_password [findstr $payload $static::password_tag "9" "\r\n"]

    set auth_header_b64 [b64encode "$raw_customerid-$raw_username:$raw_password"]
    HTTP::header insert "Authorization" "Basic $auth_header_b64"
}
when HTTP_REQUEST_RELEASE {
    #Don't pass header to backend
    HTTP::header remove "Authorization"
}

 

Don't forget to mask the values of the Authorization header in the ASM policy.