blog/content/posts/cracking_att_vvm_cipher.md

216 lines
8.9 KiB
Markdown

---
title: "Cracking the AT&T VVM cipher"
tags: ["Hacking"]
date: 2024-06-19T20:16:00-04:00
draft: false
---
I've been wanting to get the AT&T visual voicemail "protocol" (ADVVM) working
in the LineageOS dialer. I thought I had made a breakthrough with the discovery
of the prefix in front of the VVM mail server address:
```
srv=2:vvm.mobile.att.net
```
[Another user raised an issue pointing to the same
thing](https://gitlab.com/LineageOS/issues/android/-/issues/5088).
However, this was only the beginning of the fun. As [another user
discovered](https://gitlab.com/LineageOS/issues/android/-/issues/6964), AT&T
either has a bug with their concatenated SMS, or has intentionally broken the
STATUS SMS.
This brings us to the subject of the mysterious data SMS coming in on port 5499
that I have wondered about ever since I discovered them in the logs when first
"implementing" ADVVM. They can be triggered by sending a message of this format:
```
GET?c=ATTV:<device name>/<android short version>:<app version>&v=1.0&l=<10-digit phone number>&AD
```
These SMS seemingly contain everything *useful* that the STATUS SMS contains,
with several problems:
1. The password/PIN is ciphered.
2. The password/PIN field isn't always populated in response to these GET
messages. It is populated on password changes though.
Not to be thwarted, I quickly created a lookup table of the cipher by
repeatedly resetting my password via the legacy dial-in TUI and reading the
data SMS using [VvmSmsReceiver](https://git.beckmeyer.us/TnSb/VvmSmsReceiver).
This led me to the discovery of two quirks:
1. while the system will happily let you put a 15-digit password in, only the
first 10 digits are ciphered. This immediately made me think that the secret
may be based on the user's phone number without country code, since that is 10
digits. I generated a lookup table with a second throwaway AT&T line, which I
am using here in my examples rather than my actual number. This confirmed that
there is a unique secret involved as the lookup tables were different.
2. The system only generates a data SMS containing the password cipher when
the password is 11 digits or less. Otherwise, it sends a data SMS with the
p/P fields blank. This may cause some problems for anyone wanting to use this
in an implementation.
Looking at the dictionary of characters that the cipher used, I realized that
they were characters 0x50 through ox5f in ASCII. However, the ordering of the
cipher changed with each digit, which seemed to confirm that the cipher was
using some sort of shifting based on the 10 digit secret. The question was,
how?
Our dictionary is self-contained in an upper 4-bit prefix. Let's focus on the
bottom four bits (or [nibble](https://en.wikipedia.org/wiki/Nibble)) by
removing the upper bits:
```
def strip_prefix(char):
return ord(char)&0x0f
```
Now we can create a bytearray containing the stripped versions of the passcode:
```
def get_stripped(text):
stripped = [strip_prefix(c) for c in text]
return bytearray(stripped)
```
Now, how do we actually figure out the transform? There are a number of ciphers
that could be used, and we could certainly figure out the substitution table
for each character. However, this wouldn't tell us how the 10-digit secret
is involved and thus would be unique to this phone number.
ChatGPT to the rescue! When asked about ciphers that operate on bits, it
outputs a bunch of information, but mentions XOR all throughout its answer.
Duh! XOR is a reversible, non-destructive operator that [works great for
ciphering](https://en.m.wikipedia.org/wiki/XOR_cipher).
Let's try it:
```
def decode(cipher, secret):
cipher = get_stripped(cipher)
secret = get_stripped(secret)
# remember that the cipher "passes through" digits past the 10th, so we
# just overwrite the first 10
text = [i^j for i, j in zip(cipher, secret)]
if len(cipher) > 10:
text += cipher[10:]
return text
decode("[VW^QW\\W_X0", "7345839476")
[12, 5, 3, 11, 9, 4, 5, 3, 8, 14, 0]
```
Well, this doesn't quite work. When run with the ciphertext and phone number,
it doesn't output the plaintext password that I am expecting. Not to worry,
because we can also use the plaintext instead of the actual secret to gain some
insight. Below is the one with my throwaway number:
```
decode("[VW^QW\\W_X0", "00000000000")
[11, 6, 7, 14, 1, 7, 12, 7, 15, 8, 0, 0]
```
Ignore the slight bug due to an assumption about the length of the secret :|
The output of this is different for my throwaway and my actual phone number. So
there is a unique 10-digit secret. Let's try a two-step decode:
```
first_pass = ''.join(chr(i) for i in decode("[VW^QW\\W_X0", "7345839476"))
decode(first_pass, "00000000000")
[12, 5, 3, 11, 9, 4, 5, 3, 8, 14, 0, 0]
```
Now this is interesting! The output of this is the same for both of my phone
lines. Additionally, it is identical to the output from my initial decode
above. I think we've found a secondary secret, meaning the algorithm is:
```
secret XOR phonenumber XOR plaintext = ciphertext
```
Let's verify:
```
def decode(cipher, phonenumber, secret):
cipher = get_stripped(cipher)
phonenumber = get_stripped(phonenumber)
secret = [12, 5, 3, 11, 9, 4, 5, 3, 8, 14]
# remember that the cipher "passes through" digits past the 10th, so we
# just overwrite the first 10
text = [i^j^k for i, j, k in zip(cipher, phonenumber, secret)]
if len(cipher) > 10:
text += cipher[10:]
return text
decode("[VW^QW\\W_X0", "7345839476")
[0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]
decode("[WU]URZPWQ", "7345839476")
[0, 1, 2, 3, 4, 5, 6, 7, 8, 9]
```
Beatiful! This yields the same result for both phone numbers.
Let's validate this more completely:
```
lookup_table = [
['[', 'V', 'W', '^', 'Q', 'W', '\\', 'W', '_', 'X'],
['Z', 'W', 'V', '_', 'P', 'V', ']', 'V', '^', 'Y'],
['Y', 'T', 'U', '\\', 'S', 'U', '^', 'U', ']', 'Z'],
['X', 'U', 'T', ']', 'R', 'T', '_', 'T', '\\', '['],
['_', 'R', 'S', 'Z', 'U', 'S', 'X', 'S', '[', '\\'],
['^', 'S', 'R', '[', 'T', 'R', 'Y', 'R', 'Z', ']'],
[']', 'P', 'Q', 'X', 'W', 'Q', 'Z', 'Q', 'Y', '^'],
['\\', 'Q', 'P', 'Y', 'V', 'P', '[', 'P', 'X', '_'],
['S', '^', '_', 'V', 'Y', '_', 'T', '_', 'W', 'P'],
['R', '_', '^', 'W', 'X', '^', 'U', '^', 'V', 'Q'],
]
def validate_decode(table):
for plaintext_char in range(10):
expected_plaintext = str(plaintext_char) * 10
ciphertext = ''.join([table[plaintext_char][i] for i in range(10)])
plaintext = ''.join([str(i) for i in decode(ciphertext, "7345839476")])
if plaintext != expected_plaintext:
print(f"Failed on \"{plaintext}\" != decode(\"{ciphertext}\", ...)")
else:
print(f"Success! decode(\"{ciphertext}\", ...) == \"{plaintext}\"")
validate_decode(lookup_table)
Success! decode("[VW^QW\W_X", ...) == "0000000000"
Success! decode("ZWV_PV]V^Y", ...) == "1111111111"
Success! decode("YTU\SU^U]Z", ...) == "2222222222"
Success! decode("XUT]RT_T\[", ...) == "3333333333"
Success! decode("_RSZUSXS[\", ...) == "4444444444"
Success! decode("^SR[TRYRZ]", ...) == "5555555555"
Success! decode("]PQXWQZQY^", ...) == "6666666666"
Success! decode("\QPYVP[PX_", ...) == "7777777777"
Success! decode("S^_VY_T_WP", ...) == "8888888888"
Success! decode("R_^WX^U^VQ", ...) == "9999999999"
```
Addendum: After writing the majority of this, [a user pointed
out](https://gitlab.com/LineageOS/issues/android/-/issues/6964#note_1961619585)
that there is already some documentation on this ciphering, so I took a look.
Unfortunately, it looks like the cipher method has changed as this does not
work for me. I verified that the number and lookup table do not work with my
decode as well:
```
kop316_lookup_table = [
[ 'X', 'T', 'Q', '^', 'Z', 'S', 'U', 'U', '_', 'Y' ],
[ 'Y', 'U', 'P', '_', '[', 'R', 'T', 'T', '^', 'X' ],
[ 'Z', 'V', 'S', '\\', 'X', 'Q', 'W', 'W', ']', '[' ],
[ '[', 'W', 'R', ']', 'Y', 'P', 'V', 'V', '\\', 'Z' ],
[ '\\', 'P', 'U', 'Z', '^', 'W', 'Q', 'Q', '[', ']' ],
[ ']', 'Q', 'T', '[', '_', 'V', 'P', 'P', 'Z', '\\' ],
[ '^', 'R', 'W', 'X', '\\', 'U', 'S', 'S', 'Y', '_' ],
[ '_', 'S', 'V', 'Y', ']', 'T', 'R', 'R', 'X', '^' ],
[ 'P', '\\', 'Y', 'V', 'R', '[', ']', ']', 'W', 'Q' ],
[ 'Q', ']', 'X', 'W', 'S', 'Z', '\\', '\\', 'V', 'P' ],
]
validate_decode(kop316_lookup_table, "2065550100")
Failed on "6140620777" != decode("XTQ^ZSUU_Y", ...)
Failed on "7051731666" != decode("YUP_[RTT^X", ...)
Failed on "4362402555" != decode("ZVS\XQWW][", ...)
Failed on "5273513444" != decode("[WR]YPVV\Z", ...)
Failed on "2504264333" != decode("\PUZ^WQQ[]", ...)
Failed on "3415375222" != decode("]QT[_VPPZ\", ...)
Failed on "0726046111" != decode("^RWX\USSY_", ...)
Failed on "1637157000" != decode("_SVY]TRRX^", ...)
Failed on "14912814108151515" != decode("P\YVR[]]WQ", ...)
Failed on "15813915119141414" != decode("Q]XWSZ\\VP", ...)
```
I also tried solving with both of these alternatives instead with no luck:
```
secret XOR plaintext = ciphertext
phonenumber XOR plaintext = ciphertext
```
But neither worked.