Commit 17fa84aebe711b4f27b4944d2182225e731166c5
1 parent
d9734609
Added a tool for getting a mobile auth key from an installed LINE app.
Showing
2 changed files
with
149 additions
and
5 deletions
line-android.md
... | ... | @@ -59,19 +59,25 @@ The mobile version of LINE does not require the user to manually register an acc |
59 | 59 | automatically generated when the app is first installed. Therefore the user authentication method is |
60 | 60 | also different. |
61 | 61 | |
62 | -The mobile client stores a 15 byte unique key which is used to authenticate it to the LINE servers. | |
62 | +The Android client stores a 15 byte auth key which is used to authenticate it to the LINE servers. | |
63 | 63 | The key is stored in an encrypted settings database, but obviously since the app needs to be able to |
64 | 64 | read it, this is easy to circumvent (it turns out they essentially use an 8-bit encryption key, |
65 | -which is generated from the phone's ANDROID_ID value). | |
65 | +which is generated from the phone's ANDROID_ID value). The auth key is likely generated when the app | |
66 | +is first installed and it does not seem to ever change. | |
66 | 67 | |
67 | -The unique key along with the user ID is used to generate a hash that is used for authentication. | |
68 | +You can use the tools/view-android-settings.py script to view the auth key for your account. It | |
69 | +needs the LINE settings SQLite database and optionally the ANDROID_ID (it can also bruteforce it | |
70 | +which takes about a millisecond on a modern computer). The database on my phone is located at: | |
71 | + | |
72 | +/data/data/jp.naver.line.android/databases/naver_line | |
73 | + | |
74 | +The displayed user MID and auth key are used to generate an authentication token whenever the client | |
75 | +connects to the server. | |
68 | 76 | |
69 | 77 | (TODO) |
70 | 78 | |
71 | 79 | (TODO add example code: mobile authentication token generation) |
72 | 80 | |
73 | -(TODO add example code: obtaining the mobile auth key from the LINE settings SQLite database) | |
74 | - | |
75 | 81 | Custom Thrift protocol |
76 | 82 | ---------------------- |
77 | 83 | ... | ... |
tools/view-android-settings.py
0 → 100755
1 | +#!/usr/bin/env python3 | |
2 | + | |
3 | +# Requires: PyCrypto | |
4 | + | |
5 | +import sys | |
6 | +import base64 | |
7 | +import re | |
8 | +import sqlite3 | |
9 | +from Crypto.Cipher import AES | |
10 | + | |
11 | +def get_setting(path, name): | |
12 | + """Gets a setting value from the settings database.""" | |
13 | + | |
14 | + db = sqlite3.connect(path) | |
15 | + | |
16 | + r = db.execute("SELECT value FROM setting WHERE key = ?", (name,)).fetchone() | |
17 | + | |
18 | + return r[0] if r else None | |
19 | + | |
20 | +def crazy_operation(key, constant): | |
21 | + """Derives an AES key from two values using an unknown algorithm.""" | |
22 | + | |
23 | + def byte(n): | |
24 | + return n & 0xff | |
25 | + | |
26 | + arr = [0] * 16 | |
27 | + arr[0] = byte(key) | |
28 | + arr[1] = byte(key - 71) | |
29 | + arr[2] = byte(key - 142) | |
30 | + | |
31 | + for i in range(3, 16): | |
32 | + arr[i] = byte(i ^ (0xffffffb9 ^ (arr[i - 3] ^ arr[i - 2]))) | |
33 | + | |
34 | + if constant < 2 and constant > -2: | |
35 | + constant = 0xfffffffffffb389d + 0xd2dfaf * constant; | |
36 | + | |
37 | + i = 0 | |
38 | + k = -7 | |
39 | + | |
40 | + larr = len(arr) | |
41 | + for _ in range(0, larr): | |
42 | + k1 = (i + 1) & (larr - 1) | |
43 | + l1 = constant * arr[k1] + k | |
44 | + k = byte(l1 >> 32) | |
45 | + i2 = l1 + k | |
46 | + | |
47 | + if i2 < k: | |
48 | + i2 += 1 | |
49 | + k += 1 | |
50 | + | |
51 | + arr[k1] = byte(-2 - i2) | |
52 | + i = k1 | |
53 | + | |
54 | + return bytes(arr) | |
55 | + | |
56 | +def decrypt_setting(value, key): | |
57 | + """Decrypts an encrypted setting using the supplied 8-bit integer key.""" | |
58 | + | |
59 | + ciphertext = base64.b64decode(value) | |
60 | + | |
61 | + # generate AES key from 8-bit key | |
62 | + aes_key = crazy_operation(key, 0xec4ba7) | |
63 | + | |
64 | + # decrypt setting with AES | |
65 | + aes = AES.new(aes_key, AES.MODE_ECB) | |
66 | + plaintext = aes.decrypt(ciphertext) | |
67 | + | |
68 | + if len(plaintext) == 0: | |
69 | + return plaintext | |
70 | + | |
71 | + # remove PKCS#7 padding | |
72 | + plaintext = plaintext[0:-plaintext[-1]] | |
73 | + | |
74 | + return plaintext | |
75 | + | |
76 | +def java_string_hash(string): | |
77 | + """Equivalent of java.lang.String.hashCode().""" | |
78 | + | |
79 | + r = 0 | |
80 | + for c in string: | |
81 | + r = (31 * r + ord(c)) & 0xffffffff | |
82 | + | |
83 | + return r | |
84 | + | |
85 | +def is_profile_auth_key(value): | |
86 | + """Checks if a value looks like a PROFILE_AUTH_KEY.""" | |
87 | + | |
88 | + return re.match(b"^u[a-z0-9]{32}:[a-zA-Z0-9+/]+$", value) != None | |
89 | + | |
90 | +def bruteforce_key(auth_key_value): | |
91 | + """Brute forces the key for a PROFILE_AUTH_KEY.""" | |
92 | + | |
93 | + for key in range(0x00, 0xff): | |
94 | + plaintext = decrypt_setting(auth_key_value, key) | |
95 | + | |
96 | + if is_profile_auth_key(plaintext): | |
97 | + return key | |
98 | + | |
99 | + raise Exception("Couldn't brute force key.") | |
100 | + | |
101 | +def get_encrypted_setting(path, key, name): | |
102 | + """Shorthand for getting the value of an encrypted settings.""" | |
103 | + | |
104 | + value = get_setting(path, name) | |
105 | + if value is None: | |
106 | + return None | |
107 | + | |
108 | + return decrypt_setting(value, key).decode("utf-8") | |
109 | + | |
110 | +if __name__ == "__main__": | |
111 | + if len(sys.argv) == 1: | |
112 | + print("USAGE: {0} SQLITE_DB [ANDROID_ID]".format(sys.argv[0])) | |
113 | + print() | |
114 | + sys.exit(1) | |
115 | + | |
116 | + path = sys.argv[1] | |
117 | + | |
118 | + auth_key_value = get_setting(path, "PROFILE_AUTH_KEY") | |
119 | + | |
120 | + if len(sys.argv) == 2: | |
121 | + print("No ANDROID_ID given, bruteforcing.") | |
122 | + key = bruteforce_key(auth_key_value) | |
123 | + else: | |
124 | + key = java_string_hash(sys.argv[2]) | |
125 | + | |
126 | + auth_key_plaintext = decrypt_setting(auth_key_value, key) | |
127 | + if not is_profile_auth_key(auth_key_plaintext): | |
128 | + print("Key seems to be wrong.") | |
129 | + sys.exit(2) | |
130 | + | |
131 | + mid, auth_key = get_encrypted_setting(path, key, "PROFILE_AUTH_KEY").split(":") | |
132 | + | |
133 | + print("User MID: " + mid) | |
134 | + print("Auth key: " + auth_key) | |
135 | + print("Name: " + get_encrypted_setting(path, key, "PROFILE_NAME")) | |
136 | + print("LINE ID: " + get_encrypted_setting(path, key, "PROFILE_ID")) | |
137 | + print("Region: " + get_encrypted_setting(path, key, "PROFILE_REGION")) | |
138 | + print("Phone: " + get_encrypted_setting(path, key, "PROFILE_NORMALIZED_PHONE")) | ... | ... |