From 8d0d5036db1a880bf206732a46c03fbe85293528 Mon Sep 17 00:00:00 2001 From: Joel Beckmeyer Date: Fri, 21 Jun 2024 11:45:21 -0400 Subject: [PATCH] new blog post about ADVVM --- content/posts/cracking_att_vvm_cipher.md | 215 +++++++++++++++++++++++ 1 file changed, 215 insertions(+) create mode 100644 content/posts/cracking_att_vvm_cipher.md diff --git a/content/posts/cracking_att_vvm_cipher.md b/content/posts/cracking_att_vvm_cipher.md new file mode 100644 index 0000000..e2b911d --- /dev/null +++ b/content/posts/cracking_att_vvm_cipher.md @@ -0,0 +1,215 @@ +--- +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:/:&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 thesee alternatives instead with no luck: +``` +secret XOR plaintext = ciphertext +phonenumber XOR plaintext = ciphertext +``` +But neither worked.