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:
Another user raised an issue pointing to the same
thing.
However, this was only the beginning of the fun. As another user
discovered, 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:
- The password/PIN is ciphered.
- 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.
This led me to the discovery of two quirks:
- 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.
- 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) by
removing the upper bits:
def get_stripped(text):
return [ord(c) & 0x0f for c in text]
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.
Let’s try it:
def xor_cipher(cipher, secret):
if isinstance(cipher[0], str):
cipher = get_stripped(cipher)
if isinstance(secret[0], str):
secret = get_stripped(secret)
# remember that the cipher "passes through" digits past the length of the
# secret, so we just take the rest unciphered
text = [i^j for i, j in zip(cipher, secret)]
if len(cipher) > len(secret):
text += cipher[len(secret):]
return text
xor_cipher("[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:
xor_cipher("[VW^QW\\W_X0", "00000000000")
[11, 6, 7, 14, 1, 7, 12, 7, 15, 8, 0]
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 xor_cipher("[VW^QW\\W_X0", "7345839476"))
xor_cipher(first_pass, "00000000000")
[12, 5, 3, 11, 9, 4, 5, 3, 8, 14, 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 = [12, 5, 3, 11, 9, 4, 5, 3, 8, 14]
first_pass = ''.join(chr(i) for i in xor_cipher(cipher, phonenumber))
return xor_cipher(first_pass, secret)
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. Our secret is
[12, 5, 3, 11, 9, 4, 5, 3, 8, 14].
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, phone):
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, phone)])
if plaintext != expected_plaintext:
print(f'Failed on "{plaintext}" != decode("{ciphertext}", ...)')
else:
print(f'Success! decode("{ciphertext}", ...) == "{plaintext}"')
validate_decode(lookup_table, "7345839476")
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
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.
There is a secret that works for decoding this, and we can find it by following
the same method from above, using the fact that
ciphertext XOR plaintext = secret
Let’s try:
xor_cipher("XTQ^ZSUU_Y", "0000000000")
[8, 4, 1, 14, 10, 3, 5, 5, 15, 9]
And then validating it:
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"],
]
def old_decode(cipher):
cipher = get_stripped(cipher)
secret = [8, 4, 1, 14, 10, 3, 5, 5, 15, 9]
return xor_cipher(cipher, secret)
def validate_old_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 old_decode(ciphertext)])
if plaintext != expected_plaintext:
print(f'Failed on "{plaintext}" != decode("{ciphertext}", ...)')
else:
print(f'Success! decode("{ciphertext}", ...) == "{plaintext}"')
validate_old_decode(kop316_lookup_table)
Success! decode("XTQ^ZSUU_Y", ...) == "0000000000"
Success! decode("YUP_[RTT^X", ...) == "1111111111"
Success! decode("ZVS\XQWW][", ...) == "2222222222"
Success! decode("[WR]YPVV\Z", ...) == "3333333333"
Success! decode("\PUZ^WQQ[]", ...) == "4444444444"
Success! decode("]QT[_VPPZ\", ...) == "5555555555"
Success! decode("^RWX\USSY_", ...) == "6666666666"
Success! decode("_SVY]TRRX^", ...) == "7777777777"
Success! decode("P\YVR[]]WQ", ...) == "8888888888"
Success! decode("Q]XWSZ\\VP", ...) == "9999999999"
To reiterate, the secret used here was [8, 4, 1, 14, 10, 3, 5, 5, 15, 9]
.