Compare commits
8 Commits
master
..
81c77f19ad
| Author | SHA1 | Date | |
|---|---|---|---|
| 81c77f19ad | |||
| 098811f339 | |||
| 8784e1f86f | |||
| c0e7c5e253 | |||
| b6fb086817 | |||
| 8f6b1b72d5 | |||
| 59e0f14497 | |||
| d2d7ad9534 |
+4
-1
@@ -1,3 +1,6 @@
|
||||
[submodule "themes/no-js-hugo-theme"]
|
||||
path = themes/no-js-hugo-theme
|
||||
url = https://github.com/stevenengler/no-js-hugo-theme.git
|
||||
[submodule "themes/contrast-hugo"]
|
||||
path = themes/contrast-hugo
|
||||
url = https://git.beckmeyer.us/TnSb/contrast-hugo.git
|
||||
url = https://github.com/niklasbuschmann/contrast-hugo.git
|
||||
|
||||
+4
-17
@@ -2,9 +2,8 @@ baseURL = "https://beckmeyer.us/"
|
||||
languageCode = 'en-us'
|
||||
title = "Joel Beckmeyer's Homepage"
|
||||
theme = "contrast-hugo"
|
||||
enableGitInfo = true
|
||||
|
||||
[params.author]
|
||||
[author]
|
||||
name = "Joel Beckmeyer"
|
||||
email = "joel@beckmeyer.us"
|
||||
|
||||
@@ -30,28 +29,16 @@ enableGitInfo = true
|
||||
|
||||
[markup]
|
||||
[markup.highlight]
|
||||
anchorLineNos = false
|
||||
codeFences = true
|
||||
guessSyntax = true
|
||||
hl_Lines = ''
|
||||
hl_inline = false
|
||||
lineAnchors = ''
|
||||
lineNoStart = 1
|
||||
lineNos = false
|
||||
lineNumbersInTable = true
|
||||
noClasses = true
|
||||
noHl = false
|
||||
style = "gruvbox"
|
||||
tabWidth = 4
|
||||
codeFences = true
|
||||
noClasses = false
|
||||
|
||||
[markup.tableOfContents]
|
||||
startLevel = 1
|
||||
endLevel = 6
|
||||
|
||||
[params]
|
||||
matrixAddress = "matrix:u/joel:thebeckmeyers.xyz"
|
||||
# footer text at the bottom of every page
|
||||
footerText = "Have any questions? Let me know on [Matrix]({{<param matrixAddress>}})!"
|
||||
footerText = "Have any questions? Let me know on [Matrix](https://matrix.to/#/@joel:thebeckmeyers.xyz), or start a discussion on [Fediverse](https://social.beckmeyer.us/TinfoilSubmarine)!"
|
||||
# enable the table of content on pages with more than this many words
|
||||
# (negative to disable)
|
||||
# (can be overridden per-page by setting the 'toc' front matter parameter)
|
||||
|
||||
+3
-1
@@ -7,4 +7,6 @@ menu:
|
||||
|
||||
# Welcome!
|
||||
|
||||
You can find me on [Matrix]({{<param matrixAddress>}}).
|
||||
You can find me on the [Fediverse](https://social.beckmeyer.us/TinfoilSubmarine) and [Matrix](https://matrix.to/#/@joel:thebeckmeyers.xyz).
|
||||
|
||||
[What is the Fediverse?](https://pleroma.social/blog/2021/01/13/the-big-pleroma-and-fediverse-faq/)
|
||||
|
||||
+2
-1
@@ -6,4 +6,5 @@ menu:
|
||||
---
|
||||
|
||||
Joel Beckmeyer\
|
||||
Matrix: [@joel:thebeckmeyers.xyz]({{<param matrixAddress>}})
|
||||
Matrix: [@joel:thebeckmeyers.xyz](https://matrix.to/#/@joel:thebeckmeyers.xyz)\
|
||||
Fediverse: [@TinfoilSubmarine@social.beckmeyer.us](https://social.beckmeyer.us/TinfoilSubmarine)
|
||||
|
||||
@@ -1,265 +0,0 @@
|
||||
---
|
||||
title: "Cracking the AT&T VVM cipher"
|
||||
tags: ["Hacking"]
|
||||
date: 2024-06-19
|
||||
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:
|
||||
```url
|
||||
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:
|
||||
```url
|
||||
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:
|
||||
```python
|
||||
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](https://en.m.wikipedia.org/wiki/XOR_cipher).
|
||||
|
||||
Let's try it:
|
||||
```python
|
||||
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:
|
||||
```python
|
||||
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:
|
||||
```python
|
||||
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:
|
||||
```pseudocode
|
||||
secret XOR phonenumber XOR plaintext = ciphertext
|
||||
```
|
||||
Let's verify:
|
||||
```python
|
||||
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:
|
||||
```python
|
||||
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")
|
||||
```
|
||||
```stdout
|
||||
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:
|
||||
```python
|
||||
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")
|
||||
```
|
||||
```stdout
|
||||
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:
|
||||
```pseudocode
|
||||
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
|
||||
```pseudocode
|
||||
ciphertext XOR plaintext = secret
|
||||
```
|
||||
|
||||
Let's try:
|
||||
```python
|
||||
xor_cipher("XTQ^ZSUU_Y", "0000000000")
|
||||
[8, 4, 1, 14, 10, 3, 5, 5, 15, 9]
|
||||
```
|
||||
And then validating it:
|
||||
```python
|
||||
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)
|
||||
```
|
||||
```stdout
|
||||
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]`.
|
||||
@@ -1,6 +1,6 @@
|
||||
---
|
||||
title: "Hello doas"
|
||||
tags: ["Linux", "Hacking"]
|
||||
tags: ["Linux"]
|
||||
date: 2021-01-30T15:15:55-05:00
|
||||
draft: false
|
||||
---
|
||||
|
||||
+1
-1
Submodule themes/contrast-hugo updated: ba427a30a7...9b3ec3d024
Reference in New Issue
Block a user