totsugeki
totsugeki copied to clipboard
Broken by December patch?
Totsugeki doens't seem functional after the December patch. On one hand, the speedups aren't really necessary any more, but the rating-update integration is nice and I was hoping to use totsugeki to help decode the new API - they seem to have done something other than just change the version number and I'm having trouble reverse engineering it.
While connection times aren't obcenely bad anymore, Totsugeki was still better and stations still fail way too often compared to just hop and play.
I've started looking into this & have so far been able to capture & decrypt the api traffic successfully using other tools. For example, I can see a (possibly) new endpoint /api/user/save_cross_user_id
that occurs immediately after the expected /api/user/login
.
(However, I hadn't looked into this before the current patch, so forgive me if this was already present & well-known)
If anyone knows, what are the steps needed to fix Totsugeki (mostly for the purpose of suporting @halvnykterist's rating update)? I hadn't used Totsugeki or looked at the internals before today, so any clues are appreciated. Otherwise, I'll keep working on trying to reverse the api changes & post updates when I can.
Fantastic! Can you share which tools you're using?
The route I'm mainly interested in is /api/catalog/get_replay
, although other paths might be interesting too for getting additional data on users.
Fantastic! Can you share which tools you're using?
I used proxifier to proxy all (Or just GGST) traffic through to polarproxy which decrypts & writes to a .pcap
file for inspection in wireshark.
The route I'm mainly interested in is
/api/catalog/get_replay
, although other paths might be interesting too for getting additional data on users.
I'll poke around the get_replay endpoint then too. I'll start by toggling the new "platform" field in the replay search-menu & see what ticks. At minimum, there's likely a new u8
field corresponding to 'all' vs 'same' platform in the RequestQuery structure.
So I went and used this method and am I crazy or are these no longer msgpack?
a few examples:
data=W-SV4L61mtkFzNJ90I9y3_yn062-YQk9Feo01Ce34lyfabRryOdJRSe5A4kQbcy3rz5UjoW8r0-7YQQYtyMgm1lRZzwHbgX23A
data=Ye_3VzHXDZ-hXZ-7KdGtJpiELTjrLuDQWmSfqgovV1zjPKBf0ieU_ip_G90gPW064XT7dVEBf7JWpWL8V-tvqG6k3k0b9ocheH8kUNef-7PR1dpVj59r
data=jyTGYmxdFS4iP8IAQMasVX48yFGCDc9PqD1lWIqEQ50wRo4hXhNwWOkJ1poB_1givDa2cDCitn-3-B443wGTs9aQCs8Gif2X7JB3s9l78IJ5VUb8aDS9
data=AQ8fqyHCGzSTGGDc6H5hxOicIWO_tJJQ5xlD0IOJIzS1pNt_EBCY673EGFaEWH_0FsR2f5NvO5UgV7-JoVQrat6QuoTOh9xC5UJOJJb0pCJKL6rIJU1h
data=wedlqiPPTpbo4DAkfWfRDlMaCJThbgCj5GGjW9WsfZJpRMAHdnpDwI1EJ018POH3va4upQWso3nnz4iMUZcgA9EM2KJ0KOqDN6fqAYQKFSoYPHXN0W7I
I don't have any prior experience with messagepack, but using your first example & doing a quick test with cyberchef. I can't seem to decode the data as messagepack either.
However, I don't any pre-patch data to compare it to so I cannot say for sure that I haven't just made a mistake.
Perhaps they moved to protobuf instead?
After a long ass day I figured it out!
it's still using msgpack but it's actually encrypted now!
they added GCM encryption to the requests, i've dumped they key and managed to decrypt outgoing requests, here's a small python script w/ info + examples.
from binascii import hexlify, unhexlify
from base64 import urlsafe_b64decode
from Cryptodome.Cipher import AES
# key obtained from hooking strive EVP_EncryptInit_ex_0
# RVA: 0x3036460
# Encoding + Concating RVA: 0xB248D0
# GGST Timestamp: 63906742
key = unhexlify('EEBC1F57487F51921C0465665F8AE6D1658BB26DE6F8A069A3520293A572078F')
# examples from my pcap dump for catalog/get_replay
examples = [
'Ye_3VzHXDZ-hXZ-7KdGtJpiELTjrLuDQWmSfqgovV1zjPKBf0ieU_ip_G90gPW064XT7dVEBf7JWpWL8V-tvqG6k3k0b9ocheH8kUNef-7PR1dpVj59r',
'jyTGYmxdFS4iP8IAQMasVX48yFGCDc9PqD1lWIqEQ50wRo4hXhNwWOkJ1poB_1givDa2cDCitn-3-B443wGTs9aQCs8Gif2X7JB3s9l78IJ5VUb8aDS9',
'AQ8fqyHCGzSTGGDc6H5hxOicIWO_tJJQ5xlD0IOJIzS1pNt_EBCY673EGFaEWH_0FsR2f5NvO5UgV7-JoVQrat6QuoTOh9xC5UJOJJb0pCJKL6rIJU1h',
'wedlqiPPTpbo4DAkfWfRDlMaCJThbgCj5GGjW9WsfZJpRMAHdnpDwI1EJ018POH3va4upQWso3nnz4iMUZcgA9EM2KJ0KOqDN6fqAYQKFSoYPHXN0W7I'
]
for example in examples:
decoded = urlsafe_b64decode(example)
iv = decoded[:12]
cipher = AES.new(key, AES.MODE_GCM, iv)
decrypted = cipher.decrypt(decoded[12:])
print(f"Message pack: \n{hexlify(decrypted)}\n")```
Damn, you beat me to the punch! Good idea on hooking EVP_EncryptInit.
my dumb self originally hooked decrypt and stumbled on IV for a bit too long :( but glad we got it!
Nice! Going to write up some stuff locally to hopefully encrypt/decrypt requests.
Do you think the responses work in a similar way? I've noticed that the responses for replays all seem to start with 0020000000000000d5
Yes, it seems to work similarly for responses. Here is one decrypted response from my traffic last night from the endpoint /api/catalog/get_replay
:
[
[
"63a3eedaa0aac",
0,
"2022/12/22 05:44:58",
"0.1.7",
"0.0.2",
"0.0.2",
"",
""
],
[
0,
0,
5,
[
[
221222054453618850,
15,
99,
21,
12,
[
"210608040603294336",
"dlegooh",
"6239017313081128764",
"dlegooh_",
1,
9
],
[
"210611072546981835",
"Waeve",
"76561197988647455",
"110000101b1121f",
3,
9
],
2,
"2022-12-22 05:44:53",
1,
0,
0,
0
],
[
221222054443297380,
15,
9,
12,
21,
[
"210814203235952623",
"GOTE-SAMA",
"76561198216174113",
"11000010f40da21",
3,
9
],
[
"210719213919866739",
"Cloud Strife",
"2942875306480629096",
"TheREalCloud160",
1,
6
],
1,
"2022-12-22 05:44:45",
1,
0,
0,
0
],
[
221222054340881020,
15,
10,
21,
12,
[
"211229080611433091",
"CrippledCart",
"76561198192746828",
"11000010ddb614c",
3,
9
],
[
"210608222155215755",
"Name to go",
"3719338700773288793",
"Muellerboyz2",
1,
9
],
2,
"2022-12-22 05:43:40",
1,
0,
0,
0
],
[
221222054316694900,
15,
99,
12,
21,
[
"210626192602429871",
"AS",
"76561198325175998",
"110000115c016be",
3,
9
],
[
"210611191934397127",
"Front-Ward",
"76561198354052885",
"11000011778b715",
3,
9
],
1,
"2022-12-22 05:43:22",
1,
0,
0,
0
],
[
221222054306381100,
15,
99,
21,
12,
[
"210608040603294336",
"dlegooh",
"6239017313081128764",
"dlegooh_",
1,
9
],
[
"210611072546981835",
"Waeve",
"76561197988647455",
"110000101b1121f",
3,
9
],
2,
"2022-12-22 05:43:06",
1,
0,
0,
0
]
]
]
]
The above can be reproduced with a slight tweak to @Theoretical's snippet:
from binascii import unhexlify
from Crypto.Cipher import AES # pycryptodome
import json
import msgpack
# key obtained from hooking strive EVP_EncryptInit_ex_0
# RVA: 0x3036460
# Encoding + Concating RVA: 0xB248D0
# GGST Timestamp: 63906742
key = unhexlify('EEBC1F57487F51921C0465665F8AE6D1658BB26DE6F8A069A3520293A572078F')
# response from pcap dump for /api/catalog/get_replay
# NOTE: raw hex stream
response = '3a5da56a5d64459fea61c12b2a3a8752ab7263b07014a9bd157364bc4ad351239362dd8eb8ab2ed8637d369e7bbbb2a63094ede47742a5d59714084a1fb34851fb50779e09184854564910d36a964dc2df37a9f424e698bd613c1575bfaad1be42a4d43ec2825302d67040ba6cc3f471f6b19b809c9c21b0815f610fb141da15594c5eecf26031823469edd0d1fe57615850fab324f90b0ebbf023a1ea3886bb52c9d4e878b56f5034ae036bf96bda786024784e5f7fd14b620c217e0cbb42295560efd9614fa583d9e1e6c4b015ce06d7d9e200b1a11e1c9bab59e5b4b8b8215333ae9b089639b4b9594968c27e46cb4967b0189599da91973f644807f2eecdcc13b76e734c9e00ce25a37e43174b62edd036c21e316372c0d3d3bc8c51e30bba7486a8f63fb65c4bbaa4c870971ce34945610717296d5613fddbef474752c99a11b965fc270e902db2621f884020de77ee60738efbcecd591f4a62a5168b8548b9151e12351e073c4fef26c5653c1a6164a6e3536651f6711c6fe26e028cc21d9d26873f74cffaa0a0e4f471d27d91d2ae9d2f6c466b9cb5ab33e69cbfeb342d94a36b6603c1b94617fea67ff10413aafa58ce50873b73c61e104703bd396de07b8a6ad99ca9aa6a3fb91dc177871c8d7dffc3c8fc4df47ed2ad6950350074b8959dcfeba24c11e76fe63591f034653479e28b8d416e2ad8d8a05e4a4205ecb09a1bf06a3efcc39370bc5c6efc5c74c27a6a01f3336bd926842c15558d9c7648a43e9c5e7ac13401787c2b303365b2f9d0118a453ad4bb0bae3c58ec93ea0410e72657d2e48b9951447adc707c704a6ec2ff7b371724adf980bfae8646a3c9595b3271984412d0cf0d51851355ceef91c62475bcc65012b2a1e98db6f7515bb7de4b52cf42ea1cf0bcef0d736023253f3972a54b60d4e0dbbae9c4b62f95bddb5cb04f2f27bc150082f84d6a98a3f689a275f551e812a1003cd925a1672ff1001ef973ea570703c3814d4bfaf78babd6aa996f6ecfdc800790035d0a9c1c85a0626419c7b4320a6907d2dce53fe282f3799d6c77fdff19c9722a2c206932a26513b1679350c4818574260f82dd2f066a11977765dfa23663802a4ee26171a81255c0efab279873116cdcc1f565c365fd23794178dcaa96658abcfd4576cb976ef0e14d91d2b372ff2358fa8456fef689ae660f94ebfb16f5404ed661e4157373569b6a4c79a0579bd7e30243e22ca4b7502c23165cf8714a92a840bb2f1fd68f20f44084cf55b0b325bb4917bbc5b11cb7505920a87d187e7f5e547c'
decoded = unhexlify(response)
iv = decoded[:12]
cipher = AES.new(key, AES.MODE_GCM, iv)
decrypted = cipher.decrypt(decoded[12:])
# NOTE: the last 16 bytes are not part of the decrypted data (auth tag?)
print(json.dumps(msgpack.unpackb(decrypted[:-16]), indent=4))
Or by just pasting your response data hex bytes directly into this cyberchef recipe
Looks like there's another 16 bytes appended to a request though :(
Gotta figure this last part out really fast!
Looks like there's another 16 bytes appended to a request though :(
I believe that's just GCM authentication tag that gets appended to the ciphertext
I tried adding that but it doesn't seem like it actually, hit me up on discord real fast: Inve#0001
It appears to be working!
Here's the code for building/sending a request. The important part is the get_replays_request
function.
from base64 import urlsafe_b64decode, urlsafe_b64encode
from binascii import unhexlify, hexlify
from Crypto.Cipher import AES # pycryptodome
from Crypto.Random import get_random_bytes
import json
import msgpack
import requests
# key obtained from hooking strive EVP_EncryptInit_ex_0
# RVA: 0x3036460
# Encoding + Concating RVA: 0xB248D0
# GGST Timestamp: 63906742
key = unhexlify('EEBC1F57487F51921C0465665F8AE6D1658BB26DE6F8A069A3520293A572078F')
def main():
get_replays_request()
# Test request for /api/catalog/get_replay
# '5277536c36544a4f55727359624263437963506a6a47736f6e396b356158766b3470463755326c5f6b536449516479446e5954595464594b5f5563795f734e366c4432456b4146524e70476d68466266347734337a5a474f7345354665316d736d6948514832646b64705138475f30362d616151' # 00
# '675933724531555a493365766a67766274357753625061545542467762747770436451686f4a51335175554e2d30616861586130387262595358793537317236765848314f72633344736c437559735835504b57556a41665a6f63686e5472497266786b655277745174396c624d4d7038714f63' # 00
#
# Decrypt request data, provided as hex string
def decrypt_request_data(data):
decoded = urlsafe_b64decode(unhexlify(data))
iv = decoded[:12]
cipher = AES.new(key, AES.MODE_GCM, iv)
decrypted = cipher.decrypt(decoded[12:])
return msgpack.unpackb(decrypted[:-16])
# Test response for /api/catalog/get_replay
# '3a5da56a5d64459fea61c12b2a3a8752ab7263b07014a9bd157364bc4ad351239362dd8eb8ab2ed8637d369e7bbbb2a63094ede47742a5d59714084a1fb34851fb50779e09184854564910d36a964dc2df37a9f424e698bd613c1575bfaad1be42a4d43ec2825302d67040ba6cc3f471f6b19b809c9c21b0815f610fb141da15594c5eecf26031823469edd0d1fe57615850fab324f90b0ebbf023a1ea3886bb52c9d4e878b56f5034ae036bf96bda786024784e5f7fd14b620c217e0cbb42295560efd9614fa583d9e1e6c4b015ce06d7d9e200b1a11e1c9bab59e5b4b8b8215333ae9b089639b4b9594968c27e46cb4967b0189599da91973f644807f2eecdcc13b76e734c9e00ce25a37e43174b62edd036c21e316372c0d3d3bc8c51e30bba7486a8f63fb65c4bbaa4c870971ce34945610717296d5613fddbef474752c99a11b965fc270e902db2621f884020de77ee60738efbcecd591f4a62a5168b8548b9151e12351e073c4fef26c5653c1a6164a6e3536651f6711c6fe26e028cc21d9d26873f74cffaa0a0e4f471d27d91d2ae9d2f6c466b9cb5ab33e69cbfeb342d94a36b6603c1b94617fea67ff10413aafa58ce50873b73c61e104703bd396de07b8a6ad99ca9aa6a3fb91dc177871c8d7dffc3c8fc4df47ed2ad6950350074b8959dcfeba24c11e76fe63591f034653479e28b8d416e2ad8d8a05e4a4205ecb09a1bf06a3efcc39370bc5c6efc5c74c27a6a01f3336bd926842c15558d9c7648a43e9c5e7ac13401787c2b303365b2f9d0118a453ad4bb0bae3c58ec93ea0410e72657d2e48b9951447adc707c704a6ec2ff7b371724adf980bfae8646a3c9595b3271984412d0cf0d51851355ceef91c62475bcc65012b2a1e98db6f7515bb7de4b52cf42ea1cf0bcef0d736023253f3972a54b60d4e0dbbae9c4b62f95bddb5cb04f2f27bc150082f84d6a98a3f689a275f551e812a1003cd925a1672ff1001ef973ea570703c3814d4bfaf78babd6aa996f6ecfdc800790035d0a9c1c85a0626419c7b4320a6907d2dce53fe282f3799d6c77fdff19c9722a2c206932a26513b1679350c4818574260f82dd2f066a11977765dfa23663802a4ee26171a81255c0efab279873116cdcc1f565c365fd23794178dcaa96658abcfd4576cb976ef0e14d91d2b372ff2358fa8456fef689ae660f94ebfb16f5404ed661e4157373569b6a4c79a0579bd7e30243e22ca4b7502c23165cf8714a92a840bb2f1fd68f20f44084cf55b0b325bb4917bbc5b11cb7505920a87d187e7f5e547c'
#
# Decrypt response data, provided as hex string
def decrypt_response_data(data):
decoded = unhexlify(data)
iv = decoded[:12]
cipher = AES.new(key, AES.MODE_GCM, iv)
decrypted = cipher.decrypt(decoded[12:])
return msgpack.unpackb(decrypted[:-16])
# Test decryption for request and response
def test_decrypt():
# Test request decryption
request = '5277536c36544a4f55727359624263437963506a6a47736f6e396b356158766b3470463755326c5f6b536449516479446e5954595464594b5f5563795f734e366c4432456b4146524e70476d68466266347734337a5a474f7345354665316d736d6948514832646b64705138475f30362d616151'
clear_req = decrypt_request_data(request)
print(json.dumps(clear_req, indent=4))
# Test response decryption
response = '3a5da56a5d64459fea61c12b2a3a8752ab7263b07014a9bd157364bc4ad351239362dd8eb8ab2ed8637d369e7bbbb2a63094ede47742a5d59714084a1fb34851fb50779e09184854564910d36a964dc2df37a9f424e698bd613c1575bfaad1be42a4d43ec2825302d67040ba6cc3f471f6b19b809c9c21b0815f610fb141da15594c5eecf26031823469edd0d1fe57615850fab324f90b0ebbf023a1ea3886bb52c9d4e878b56f5034ae036bf96bda786024784e5f7fd14b620c217e0cbb42295560efd9614fa583d9e1e6c4b015ce06d7d9e200b1a11e1c9bab59e5b4b8b8215333ae9b089639b4b9594968c27e46cb4967b0189599da91973f644807f2eecdcc13b76e734c9e00ce25a37e43174b62edd036c21e316372c0d3d3bc8c51e30bba7486a8f63fb65c4bbaa4c870971ce34945610717296d5613fddbef474752c99a11b965fc270e902db2621f884020de77ee60738efbcecd591f4a62a5168b8548b9151e12351e073c4fef26c5653c1a6164a6e3536651f6711c6fe26e028cc21d9d26873f74cffaa0a0e4f471d27d91d2ae9d2f6c466b9cb5ab33e69cbfeb342d94a36b6603c1b94617fea67ff10413aafa58ce50873b73c61e104703bd396de07b8a6ad99ca9aa6a3fb91dc177871c8d7dffc3c8fc4df47ed2ad6950350074b8959dcfeba24c11e76fe63591f034653479e28b8d416e2ad8d8a05e4a4205ecb09a1bf06a3efcc39370bc5c6efc5c74c27a6a01f3336bd926842c15558d9c7648a43e9c5e7ac13401787c2b303365b2f9d0118a453ad4bb0bae3c58ec93ea0410e72657d2e48b9951447adc707c704a6ec2ff7b371724adf980bfae8646a3c9595b3271984412d0cf0d51851355ceef91c62475bcc65012b2a1e98db6f7515bb7de4b52cf42ea1cf0bcef0d736023253f3972a54b60d4e0dbbae9c4b62f95bddb5cb04f2f27bc150082f84d6a98a3f689a275f551e812a1003cd925a1672ff1001ef973ea570703c3814d4bfaf78babd6aa996f6ecfdc800790035d0a9c1c85a0626419c7b4320a6907d2dce53fe282f3799d6c77fdff19c9722a2c206932a26513b1679350c4818574260f82dd2f066a11977765dfa23663802a4ee26171a81255c0efab279873116cdcc1f565c365fd23794178dcaa96658abcfd4576cb976ef0e14d91d2b372ff2358fa8456fef689ae660f94ebfb16f5404ed661e4157373569b6a4c79a0579bd7e30243e22ca4b7502c23165cf8714a92a840bb2f1fd68f20f44084cf55b0b325bb4917bbc5b11cb7505920a87d187e7f5e547c'
clear_resp = decrypt_response_data(response)
print(json.dumps(clear_resp, indent=4))
# Encrypt request data, returns hex bytes by default.
# NOTE: To use as request data, unhexlify the result & urlsafe_b64encode it.
def encrypt_request_data(data):
msg = msgpack.packb(data)
iv = get_random_bytes(12)
cipher = AES.new(key, AES.MODE_GCM, iv)
encrypted = cipher.encrypt(msg)
tag = cipher.digest()
return hexlify(iv + encrypted + tag)
def get_replays_request():
data_header = [
"210611111531170608",
"63a4b3207a9b3",
2,
"0.1.7",
3
]
data_params = [
1,
0,
10,
[
-1,
0,
1,
99,
[],
-1,
-1,
0,
0,
1
],
6 # all platforms? (3 = PC?)
]
data = [data_header, data_params]
encrypted = encrypt_request_data(data)
print(f'Encrypted request data: {encrypted}')
# print(f'Sanity check: {json.dumps(decrypt_response_data(encrypted), indent=4)}')
encoded = urlsafe_b64encode(unhexlify(encrypted))
print(f'Encoded request data: {encoded}')
r = requests.post(
r'https://ggst-game.guiltygear.com/api/catalog/get_replay',
headers={
'Cache-Control': r'no-store',
'Content-Type': r'application/x-www-form-urlencoded',
'User-Agent': r'GGST/Steam',
'x-client-version': r'1',
'Content-Length': r'122'
},
data={
'data': encoded
},
)
content = r.content
# print(f'Response (hex): {content.hex()}')
print(f'Decrypted response: \n{json.dumps(decrypt_response_data(content.hex()), indent=4)}')
if __name__ == "__main__":
main()
Which produces the following response from the server:
[
[
"63a4d2a868986",
0,
"2022/12/22 21:56:56",
"0.1.7",
"0.0.2",
"0.0.2",
"",
""
],
[
0,
0,
10,
[
[
221222215030712763,
15,
10,
19,
16,
[
"210621212649463684",
"Tsuruda",
"76561198044832921",
"1100001050a6499",
3,
6
],
[
"211219202343275742",
"Sonex",
"76561198056994822",
"110000105c3f806",
3,
9
],
2,
"2022-12-23 00:50:30",
1,
0,
0,
0
],
[
221222214000059027,
15,
99,
1,
19,
[
"210611073644800042",
"Gone",
"76561198068250117",
"1100001066fb605",
3,
9
],
[
"210614071944904510",
"Free",
"76561198027320062",
"110000103ff2afe",
3,
9
],
1,
"2022-12-23 00:38:34",
1,
0,
0,
0
],
[
221222213349808973,
15,
10,
9,
16,
[
"210814203235952623",
"GOTE-SAMA",
"76561198216174113",
"11000010f40da21",
3,
8
],
[
"211219202343275742",
"Sonex",
"76561198056994822",
"110000105c3f806",
3,
9
],
2,
"2022-12-23 00:33:49",
1,
0,
0,
0
],
[
221222213227454031,
15,
10,
9,
16,
[
"210814203235952623",
"GOTE-SAMA",
"76561198216174113",
"11000010f40da21",
3,
9
],
[
"211219202343275742",
"Sonex",
"76561198056994822",
"110000105c3f806",
3,
9
],
2,
"2022-12-23 00:32:27",
1,
0,
0,
0
],
[
221222213245126948,
15,
99,
1,
5,
[
"210611073644800042",
"Gone",
"76561198068250117",
"1100001066fb605",
3,
9
],
[
"210611145137490808",
"Ya Boi Squanto",
"76561198058818878",
"110000105dfcd3e",
3,
9
],
2,
"2022-12-23 00:31:19",
1,
0,
0,
0
],
[
221222212952963881,
15,
10,
9,
16,
[
"210814203235952623",
"GOTE-SAMA",
"76561198216174113",
"11000010f40da21",
3,
9
],
[
"211219202343275742",
"Sonex",
"76561198056994822",
"110000105c3f806",
3,
9
],
1,
"2022-12-23 00:29:52",
1,
0,
0,
0
],
[
221222213116149241,
15,
99,
1,
5,
[
"210611073644800042",
"Gone",
"76561198068250117",
"1100001066fb605",
3,
9
],
[
"210611145137490808",
"Ya Boi Squanto",
"76561198058818878",
"110000105dfcd3e",
3,
9
],
1,
"2022-12-23 00:29:50",
1,
0,
0,
0
],
[
221222212635786904,
15,
99,
1,
21,
[
"210611073644800042",
"Gone",
"76561198068250117",
"1100001066fb605",
3,
9
],
[
"210704200643605565",
"Guess the Meme",
"76561198989946472",
"11000013d5fae68",
3,
9
],
2,
"2022-12-23 00:25:10",
1,
1,
0,
0
],
[
221222212510328349,
15,
10,
16,
1,
[
"211219202343275742",
"Sonex",
"76561198056994822",
"110000105c3f806",
3,
9
],
[
"210707080322158563",
"Vixo",
"76561198014768536",
"1100001033fa598",
3,
9
],
1,
"2022-12-23 00:25:10",
1,
0,
0,
0
],
[
221222212433465929,
15,
99,
1,
21,
[
"210611073644800042",
"Gone",
"76561198068250117",
"1100001066fb605",
3,
9
],
[
"210704200643605565",
"Guess the Meme",
"76561198989946472",
"11000013d5fae68",
3,
9
],
1,
"2022-12-23 00:23:07",
1,
0,
0,
0
]
]
]
]
And I can confirm that it matches what I'm seeing in my game locally.
Amazing work! I'll start translating this to Rust so I can run it on the website.
Hey all, I opened a PR https://github.com/optix2000/totsugeki/pull/87 to help address this in totsugeki. I worked the example python code into a Go package, and then was able to rewrite /api/sys/get_env
so that the proxy works correctly again. The requests are getting proxied, but because I only encrypted/decrypt in one spot, GGST just errors on the rest of the endpoints. I don't know all the spots that it needs to go through the crypto code yet, digging through that now (unless someone already knows all the spots :))
I can come back to this & contribute to your PR after the holidays wrap up.
Also, something important to note is that @Theoretical discovered that the "token" (63a4b3207a9b3
in my example above) used in the requests sent to the server actually expires after a period of time, so it can't be hardcoded anymore. They tried to use my example above & it didn't work because of this token expiry. I'm guessing that @halvnykterist will probably hit the same issue if they try reusing the same token too.
However, it turns out that this token is at least partially composed of a hex timestamp. For example, the first four bytes of the aforementioned token (63a4b320
) represent 1671738144
in decimal which corresponds to Thursday, December 22, 2022 7:42:24 PM
. Which iirc is the same time I was capturing traffic & reusing the token. Therefore it might be possible to generate a valid token for each request on the fly.
I haven't had the chance to try this yet, so someone else might have made more progress in the meantime.
Any chance someone with the proxy setup could post several successful requests or responses? I've been pretty unsuccessful brute forcing the last 5 hex digits of the token (there's just over a million combinations...). I'll keep picking at it but more examples couldn't hurt.
I'm sure this is known, but it may be helpful. It seems like the first piece of the response object is the token provided in the request. The second piece appears to be a "failure" flag, in the successful responses it looked to be 0.
Decrypted response for 9999999999999:
[
[
"9999999999999",
1,
"2022/12/31 16:54:39",
"0.1.7",
"0.0.2",
"0.0.2",
"",
""
],
[
4
]
]
It seems token expired each time when the game tries to login via /api/user/login
with content
[['', '', 2, '0.1.7', 3],
[1, <stem_id>, <steam_hex_id>, 256, <unknown very long string>]]
It is not possible to have several valid tokens anymore.
And I cannot figure out how to get this very long string
from steam client to automatize getting token.
Is the very long string
different each time?
yes, a random set of hex bytes
hi everyone! the first part of the string 140000005088EF43300E7A510FF1DD0B0100100162EDB66318000000010000000200000082EC43A97F211FB1EB546E0303000000
is definitely user auth ticket as u can see on screenshot, next part is always static to me 24010000A4000000040000000FF1DD0B01001001E01E1500F44913256800A8C0000000002F0CB663AFBBD1630100E1B707001300D09017000000D19017000000D29017000000D39017000000D49017000000D59017000000D69017000000D79017000000D89017000000D82C190000009A38190000009B38190000001AC11F0000001BC11F0000001CC11F0000001DC11F0000001EC11F0000001FC11F00000020C11F0000000000
the last part might be an application ticket (5C63E04253B887B23F3D776A2B9570E87CCF76EF2A1DB8CBF2F5E38E7E454EE2E08A5CA3D66FEC9BDAB6BECF8ACF6345FF52964B33B6A02893EC4F89703F2CC6BF08A00313C3274B4EBEC232AF0293244313042DAF1C98386BC21CC4A3A39B15C272A99A690A475CDA2AFB69BA365937CDC779C38865668550A389FA3FEF95904F7AD3458A3E5AF7658653B20834295BBC47571E772850037D6F10AF565A0C372FC81F372377A7C0277F6594AEF4465838A01C75B46DFA9DA886F16ABD90305AA681CC9AB0FDB7089D323B7C6A25351A84AF73D9DD7DA8B1BB4E7ABCC006A24C),
but it's encrypted and i can't figure out how to decrypt it. Bet that only arcsys, as an app owners can.
P.S. for hooking steam api calls i'm using NetHook2
Hello everyone! I finally figured out how build GGST API token (short hexlify string). Thanks @F0cu53d for the information.
All numbers are encoded as integers (4 bytes) in little endian. <app_id> is 1384160 (GGST id in steam store)
As before, we need to send a request to https://ggst-game.guiltygear.com/api/user/login and the request body looks like this:
[1, <stem_id>, <steam_hex_id>, 256, <auth_session_ticket>]
<auth_session_ticket> structure:
<auth_ticket> (usually occupies 52 bytes)
<app_ticket_length>
<app_ticket>
<auth_ticket> structure:
<game_token_length>
<game_token>
<session_size> (always equals 24)
<unknown_1> (always equals 1)
<unknown_2> (always equals 2)
<ip_address> (obfuscated ip address, I suppose just <auth_ticket> identifier)
<unknown_3> (random number, but 0 working for me)
<connection_time> (time of session in milliseconds)
<connection_count>
Steps for getting <auth_session_ticket>
is as follows (all requests to the steam client api):
- Get
<app_ticket>
with requestClientGetAppOwnershipTicket
- Get
<game_token>
(steam client itself sends it asClientGameConnectTokens
request) - Build
<auth_ticket>
, calculate crc32, send it with requestClientAuthList
then waitClientAuthListAck
- Build
<auth_session_ticket>
(all parts we have) - Done.
The <auth_session_ticket>
will be valid until a new one is generated (or maybe expired).
I took most of the information about building <auth_session_ticket>
from this code
I did not begin to paint a detailed algorithm of work here, since I am still unsure of many things. If someone is well versed in the steam client api, then he will be able to understand what to do. In the near future I will try to make just a web server that will issue the coveted token upon request, I will write about success here.
If anyone is interested about steam client api, I use this Python library.
have you managed to successfully post a login? I broke down the code into python but get 500 for each request
game_tokens = []
session_time = None
def on_game_tokens(msg):
global game_tokens
print('??? tokens')
print(msg)
print(msg.body.tokens)
game_tokens.extend(msg.body.tokens)
def create_auth_ticket(token, session_time):
# dumb shit
session_size = 24
public_ip = get('https://checkip.amazonaws.com').text.strip()
msg = b''
msg += pack('I', len(token))
msg += token
msg += pack('III', session_size, 1, 2)
ip = inet_aton(public_ip)
ip = bytearray(ip)
ip.reverse()
msg += ip
msg += pack('III', 0, int(session_time), 1)
return msg
def build_login_token():
global game_tokens, session_time
app_id = 1384160 # Strive appId
client = SteamClient()
client.on(EMsg.ClientGameConnectTokens, on_game_tokens)
#client.on(EMsg.ClientAuthListAck, on_ticket_ack)
session_time = time()
client.cli_login()
app_ticket = client.get_app_ticket(app_id).ticket
auth_ticket = create_auth_ticket(game_tokens[0], time() - session_time)
crc = crc32(auth_ticket)
message = MsgProto(EMsg.ClientAuthList)
message.body.tokens_left = len(game_tokens)
message.body.app_ids.extend([app_id])
tickets = message.body.tickets.add()
# wtf is this proto bullshit.
ticket = {
'gameid': app_id,
'ticket': auth_ticket,
'ticket_crc': crc
}
proto_fill_from_dict(tickets, ticket)
resp = client.send_message_and_wait(message, EMsg.ClientAuthListAck)
print('Looking for ack for our ticket...')
print('Auth tickent len: %d' % len(auth_ticket))
print(resp)
# build login token
msg = auth_ticket
msg += pack('I', len(app_ticket))
msg += app_ticket
return hexlify(msg)
I got it working and spun up as small repo:
https://github.com/Theoretical/ggst-py/tree/main
Fantastic work everyone, guys! Thank you!
@v-dudnikov i was using the same kit for hooking and didn't realize that the answer was right under my nose.. 😆