Joel Beckmeyer's Homepage

Cracking the AT&T VVM cipher

Created:

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.

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:

  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. 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) 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].