216 lines
8.9 KiB
Markdown
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.
|