Commit | Line | Data |
---|---|---|
2a5bb5a5 JM |
1 | --- |
2 | title: "Reverse Engineering a Mobile App Protobuf API" | |
3 | date: 2024-05-11T12:00:00+03:00 | |
4 | description: "In recent times more than ever, live service and overuse of APIs soon-to-be-stale is only increasing. This causes a lot of once-written software to become either unusable or handicapped in many regards. One way to fight this is to learn reverse engineering for sake of digital preservation. In this blog, I take you through a journey of reverse engineering a simple mobile game's protobuf API." | |
5 | tags: ['tutorial', 'reverse-engineering', 'opensource'] | |
6 | type: blog | |
7 | draft: false | |
8 | --- | |
9 | ||
10 | # Why | |
11 | Why not? Digital preservation is important, even if you don't care for a specific program. | |
12 | This is also a good way to get started with protocol reverse engineering due to way protobuf | |
13 | is often left behind in source format within client applications. | |
14 | ||
15 | ## The target | |
16 | In this series of blogposts, I will be using a mobile game "Egg, Inc." as the target for | |
17 | demonstration. It's a simple time killer app that got me through boring long waits when I was still at school. | |
18 | ||
19 | I won't bore you with details, but in essence it's an incremental game with partial online features | |
20 | such as cloud save, co-op contracts and server scheduled boosts. | |
21 | ||
22 | ## The existing works | |
23 | In some cases, you will find previous works on the target you pick, in my case, some clever people have created | |
24 | [scripts to extract .proto file out of app.](https://github.com/DavidArthurCole/EggIncProtoExtractor) | |
25 | I advise you to check it out if you wish to get a better understanding of how you would go about retrieving the | |
26 | API spec .proto file for your target. | |
27 | ||
28 | Further there are a few dedicated individuals in the game's community who have created numerous tools and historical databases. | |
29 | ||
30 | For this blog purposes, we will assume the game server is shut down (as in we cannot query from the live API) and our goal is | |
31 | to make a semi-functional selfhosted gameserver for our own needs, assuming we are the only one on said server. | |
32 | ||
33 | ## Getting Started | |
34 | Thanks to the previously mentioned script, it's easy to get started - find the APK, extract protobuf spec file, convert it with | |
35 | protoc and we're done there. One small problem - due to cheaters, latest version of the game includes "AuthenticatedMessage" structure, | |
36 | which contains a salted sha256sum of the payload message. | |
37 | ||
38 | At this point, after a bit of internal dilemma, I decided to not further the problem while service is still live for people playing and did the | |
39 | more morally sound decision of picking a version prior to these integrity checks. We can crack that another day as all the needed information | |
40 | is retained in the app itself. | |
41 | ||
42 | Going forward with this, we are targetting game version 1.12.13 (Build ID 111121). | |
43 | ||
44 | With all that out of the way, lets get into actual commands used here: | |
45 | ``` | |
46 | git clone https://github.com/DavidArthurCole/EggIncProtoExtractor.git | |
47 | cd EggIncProtoExtractor | |
48 | ./apkextract.sh com.auxbrain.egginc_1.12.13.apk | |
49 | # We should have a new folder "protos" now with resulting files | |
50 | cd protos | |
51 | # There should be a file called ei.proto - that's our protobuf spec file | |
52 | # At this point, we can use the protoc utility which can convert the specfile | |
53 | # to interfaces in C++, C#, Java, Kotlin, Objective-C, PHP, Python and Ruby with | |
54 | # additional plugin support for Dart and Go. | |
55 | # To make this easier to understand, we will use Python in this demonstration | |
56 | protoc -I=. --python_out=. ./ei.proto | |
57 | # Success! We now have a "ei_pb2.py" file which can be directly imported to Python programs | |
58 | ``` | |
59 | ||
60 | With the protobuf interface in Python created, we can now proceed with creating the API emulator - but there's a slight problem. | |
61 | What URL? What endpoints? How do we find this out? Simple answer, disassembling the game. Get your RE tool of choice, I will be | |
62 | using [Ghidra](https://ghidra-sre.org/) myself. | |
63 | ||
64 | (Note: You can also just try to find this out using tools such as WireShark) | |
65 | ||
66 | The game contains a linked-library written in C++, which you can find inside the .apk `lib` folder, named as `libegginc.so`. | |
67 | This is perfect for our use-case, Ghidra is going to slice through this like butter. Import the file to your RE tool of choice | |
68 | and let it perform some analysis on it, have a cup of tea or coffee as this is going to take a hot minute. | |
69 | ||
70 | Once that's done, we are going to start by looking at the defined strings - try our luck there. Search for any debug prints left | |
71 | behind or maybe some clues. I started by searching for `http`, which lead me to following string `"HTTP REQ: %d"`, seems promising. | |
72 | When I jumped to it, I saw an exactly adjacent string to it which could give more clues: | |
73 | ``` | |
74 | s_www.auxbrain.com_00c02b60 XREF[0,1]: FUN_00518ab8:00518b38(R) | |
75 | 00c02b5e 47 3f 77 ds "G?www.auxbrain.com" | |
76 | 77 77 2e | |
77 | 61 75 78 | |
78 | s_HTTP_REQ:_%d_00c02b71 XREF[1]: makeRequestInternal:0067bbd4(*) | |
79 | 00c02b71 48 54 54 ds "HTTP REQ: %d" | |
80 | 50 20 52 | |
81 | 45 51 3a | |
82 | ``` | |
83 | Interesting, `www.auxbrain.com`. If we jump to its XREF, we get a garbled function, but what it seems to be doing is setting up | |
84 | certain global values. | |
85 | ||
86 | So we have a potential API endpoint, let's put it to the test. We're not going to recompile anything yet or do any byte-patching, | |
87 | let's try a quick smoke-test. Ensure your phone is rooted and you have a variant of Xposed Framework installed (I used LSPosed). | |
88 | We will need to untrip the SSL pinning present in most apps, including this one, I used [io.github.tehcneko.sslunpinning](https://github.com/Xposed-Modules-Repo/io.github.tehcneko.sslunpinning) module. | |
89 | (Note: I know it is possible to repackage the app to do SSL unpinning in most cases, but in many cases, you won't know if it's worth the effort yet) | |
90 | ||
91 | Next, install [AdAway app from F-Droid](https://f-droid.org/packages/org.adaway/) so we can setup a redirection on any network we are on. | |
92 | Inside AdAway, add a redirection rule for the address we just found and point it to an IP address in your LAN that will run the API server. | |
93 | ||
94 | Generate a self-signed certificate authority and a certificate signed by it and run a webserver with both HTTP and HTTPS on the API server machine. | |
95 | Import the self-signed CA to your phone's truststore. Once all of that is done, run the app for first time. | |
96 | ||
97 | ``` | |
98 | 192.168.1.212 - - [...] "POST /ei/first_contact HTTP/1.1" 404 0 "-" | |
99 | ``` | |
100 | ||
101 | Bingo. We have contact and we have an API endpoint. Searching for "ei/" in the strings reveals a extensive list of API endpoints, we now have something | |
102 | to go off from. We have everything we need to start creating the server. | |
103 | ||
104 | ## Implementing the Server - Getting first contact | |
105 | Next up, we create a new project - as we generated the protobuf definitions for Python, we will proceed accordingly. | |
106 | If you are following along, get respective packages for your operating system to create python venvs. | |
107 | As the protobufs are being sent over HTTP, we will be serving our application over flask which is being reverse proxied by nginx. | |
108 | ||
109 | ``` | |
110 | # Lets stage the environment | |
111 | mkdir apiserver | |
112 | cd apiserver | |
113 | python -m venv .venv | |
114 | source .venv/bin/activate | |
115 | touch app.py | |
116 | cp ~/EggIncProtoExtractor/protos/ei.proto . | |
117 | ||
118 | # Get some dependeices | |
119 | pip install protobuf | |
120 | pip install flask | |
121 | ``` | |
122 | ||
123 | We now have the project set up for reading protobuf definitions and a framework to listen for HTTP and routes sent to it. | |
124 | Let's create an actual listener application, open app.py with your favourite IDE or text editor. | |
125 | ||
126 | ``` | |
127 | import ei_pb2 as EIProto | |
128 | from flask import Flask | |
129 | from flask import request | |
130 | ||
131 | @app.route("/ei/<path:subpath>", methods=["POST"]) | |
132 | def ei_routes(subpath): | |
133 | print("HTTP POST /ei/" + subpath) | |
134 | print(request.headers) | |
135 | return "" | |
136 | ``` | |
137 | ||
138 | This should get the ball rolling, we will see whatever call comes in and we can see what the payload of each request contains. | |
139 | At this point you should setup the reverse proxy, override your nginx / directive with: | |
140 | ``` | |
141 | location / { | |
142 | proxy_pass http://127.0.0.1:5000; | |
143 | } | |
144 | ``` | |
145 | ||
146 | Reload your nginx and start the flask application you just created with `flask run`. | |
147 | ||
148 | Run the app again and have it phone home and see what it contains. | |
149 | ``` | |
150 | HTTP POST /ei/first_contact | |
151 | Host: 127.0.0.1:5000 | |
152 | Connection: close | |
153 | Content-Length: 37 | |
154 | Content-Type: application/x-www-form-urlencoded | |
155 | User-Agent: Dalvik/2.1.0 (Linux; U; Android 13; M2012K11AG Build/TQ3A.230901.001) | |
156 | Accept-Encoding: gzip | |
157 | ``` | |
158 | ||
159 | We can see there's a form payload attached to this request, let's modify our app route a bit: | |
160 | ``` | |
161 | @app.route("/ei/<path:subpath>", methods=["POST"]) | |
162 | def ei_routes(subpath): | |
163 | print("HTTP POST /ei/" + subpath) | |
164 | print(request.form) | |
165 | return "" | |
166 | ``` | |
167 | ||
168 | Now if we run the modified flask application again, we see following output on the first_contact endpoint. | |
169 | ``` | |
170 | HTTP POST /ei/first_contact | |
171 | ImmutableMultiDict([('data', 'ChAzNTVlNDZlOTA4OWQxZTRjEAAYAg==')]) | |
172 | ``` | |
173 | ||
174 | We have a base64-encoded protobuf binary data - which isn't terribly useful for reading plain-text, since protobuf *is* a binary | |
175 | format, so we will need to figure out what protobuf message this payload belongs to. | |
176 | ||
177 | Remember that ei.proto file alongside the ei_pb2.py we got earlier? Lets go back there and inspect it a bit. | |
178 | We know we just contacted something called "first_contact", maybe there is something in that file that could help us? | |
179 | ||
180 | ``` | |
181 | message EggIncFirstContactRequest { | |
182 | optional string user_id = 1; | |
183 | optional uint32 client_version = 2; | |
184 | optional Platform platform = 3; | |
185 | } | |
186 | ||
187 | message EggIncFirstContactResponse { | |
188 | optional Backup backup = 1; | |
189 | } | |
190 | ``` | |
191 | ||
192 | Seems like the application is using message names in almost similar fashion to API endpoint names themselves. This will prove | |
193 | to be useful knowledge. We now know what the payload should be, lets put this to the test. | |
194 | ||
195 | Edit your app routine again | |
196 | ``` | |
197 | # add "import base64" to top of the file | |
198 | @app.route("/ei/<path:subpath>", methods=["POST"]) | |
199 | def ei_routes(subpath): | |
200 | print("HTTP POST /ei/" + subpath) | |
201 | if subpath == "first_contact": | |
202 | # Create the protobuf object so we can load data from the b64 payload | |
203 | FirstContact = EIProto.EggIncFirstContactRequest() | |
204 | FirstContact.ParseFromString(base64.b64decode(form["data"])) | |
205 | print(FirstContact) | |
206 | else: | |
207 | print(request.form) | |
208 | return "" | |
209 | ``` | |
210 | ||
211 | We should now be able to see deserialized output when we run the flask application and the mobile app, let's try it out: | |
212 | ||
213 | ``` | |
214 | HTTP POST /ei/first_contact | |
215 | user_id: "355e46e9089d1e4c" | |
216 | client_version: 0 | |
217 | platform: DROID | |
218 | ``` | |
219 | ||
220 | Nice! We now know how to identify which protobuf object corresponds to which API endpoint. We can now make an educated guess | |
221 | on what would come next. | |
222 | ||
223 | Seeing how we got `EggIncFirstContactRequest` and saw an adjacent `EggIncFirstContactResponse` message in the proto file, we | |
224 | can safely assume that this is what the game is expecting from us in return. | |
225 | ||
226 | Lets modify the server a bit to account for that. | |
227 | ||
228 | ``` | |
229 | @app.route("/ei/<path:subpath>", methods=["POST"]) | |
230 | def ei_routes(subpath): | |
231 | print("HTTP POST /ei/" + subpath) | |
232 | if subpath == "first_contact": | |
233 | # Create the protobuf object so we can load data from the b64 payload | |
234 | FirstContact = EIProto.EggIncFirstContactRequest() | |
235 | FirstContact.ParseFromString(base64.b64decode(form["data"])) | |
236 | print("We got a first contact hello from user " + FirstContact.user_id) | |
237 | # Lets respond with a FirstContactResponse | |
238 | FirstContactResp = EIProto.EggIncFirstContactResponse() | |
239 | # This takes only one optional argument - a Backup object - but we have no account | |
240 | # system yet, so we will opt out of sending that for now. | |
241 | # --- | |
242 | # We send the payload back as a base64 string - the same way we retrieved it. | |
243 | return base64.b64encode(FirstContactResp.SerializeToString()) | |
244 | else: | |
245 | print(request.form) | |
246 | return "" | |
247 | ``` | |
248 | ||
249 | Now when we run the app again, we notice that we no longer get spammed this endpoint, but instead in its place we see a few new friends | |
250 | ||
251 | ## Implementing the Server - New Friends | |
252 | Say hello to `/ei/save_backup` and `/ei/get_periodicals`. We can infer from the name, that save_backup would involve a Backup message | |
253 | and get_periodicals would involve a GetPeriodicalsRequest, both of which are defined fully in the proto spec file. | |
254 | ||
255 | Both of these are clogging up the flask application log periodically, we should check out what they are so we can have a sane log again. | |
256 | ||
257 | ``` | |
258 | @app.route("/ei/<path:subpath>", methods=["POST"]) | |
259 | def ei_routes(subpath): | |
260 | print("HTTP POST /ei/" + subpath) | |
261 | if subpath == "first_contact": | |
262 | # Create the protobuf object so we can load data from the b64 payload | |
263 | FirstContact = EIProto.EggIncFirstContactRequest() | |
264 | FirstContact.ParseFromString(base64.b64decode(form["data"])) | |
265 | print("We got a first contact hello from user " + FirstContact.user_id) | |
266 | # Lets respond with a FirstContactResponse | |
267 | FirstContactResp = EIProto.EggIncFirstContactResponse() | |
268 | # This takes only one optional argument - a Backup object - but we have no account | |
269 | # system yet, so we will opt out of sending that for now. | |
270 | # --- | |
271 | # We send the payload back as a base64 string - the same way we retrieved it. | |
272 | return base64.b64encode(FirstContactResp.SerializeToString()) | |
273 | elif subpath == "save_backup": | |
274 | # NOTE: This took me way longer to realize than it should have, but the base64 | |
275 | # payload you receive from client is broken due to some Android bug, where it | |
276 | # substitutes "+" symbols with a " " whitespace. | |
277 | # I don't want you to waste half hour to hours figuring out why you're getting | |
278 | # corrupted data, so you're welcome. | |
279 | Backup = EIProto.Backup() | |
280 | Backup.ParseFromString(base64.b64decode(form["data"].replace(" ", "+")) | |
281 | print(Backup) | |
282 | elif subpath == "get_periodicals": | |
283 | Periodicals = EIProto.GetPeriodicalsRequest() | |
284 | Periodicals.ParseFromString(base64.b64decode(form["data"]) | |
285 | print(Periodicals) | |
286 | else: | |
287 | print(request.form) | |
288 | return "" | |
289 | ``` | |
290 | ||
291 | We should now see what these payloads actually contain when deserialized (for your reading experience, I advise you to rather | |
292 | try this out yourself - the Backup structure is VERY large). | |
293 | ||
294 | Upon these payloads reaching the server, we see that a very much populated Backup message makes its way through and a relatively | |
295 | thin payload of PeriodicalsRequest comes through, which isn't very useful by itself. | |
296 | ||
297 | Now, this is why the game developer ended up creating forms of anticheat in future versions of this app - the Backup message contains | |
298 | your entire game state, which is often sent as a way to save your progress to cloud, but there is no actual sanity checking in place | |
299 | to ensure you're not progressing way too fast. Personally, I am of the mind that anticheat should be done on the server-side, not | |
300 | on the client-side, but I digress. We can use this to prove a very obvious vulnerability when using trust-client-always architecture. | |
301 | ||
302 | The game has an In App Purchase for "Pro Permit", which allows you to build more Silos, which in turn allow you to get offline | |
303 | rewards for a longer period of time. If we look at protobuf definition file, you can see under Backup.game, a field called "permit_level", | |
304 | which by default is zero. Lets try to change that and present a modified Backup the next time user opens the game. | |
305 | ||
306 | ||
307 | ``` | |
308 | cache = {} | |
309 | ||
310 | @app.route("/ei/<path:subpath>", methods=["POST"]) | |
311 | def ei_routes(subpath): | |
312 | print("HTTP POST /ei/" + subpath) | |
313 | if subpath == "first_contact": | |
314 | # Create the protobuf object so we can load data from the b64 payload | |
315 | FirstContact = EIProto.EggIncFirstContactRequest() | |
316 | FirstContact.ParseFromString(base64.b64decode(form["data"])) | |
317 | print("We got a first contact hello from user " + FirstContact.user_id) | |
318 | # Lets respond with a FirstContactResponse | |
319 | FirstContactResp = EIProto.EggIncFirstContactResponse() | |
320 | if FirstContact.user_id in cache: | |
321 | FirstContactResp.backup.CopyFrom(cache[FirstContact.user_id]) | |
322 | del cache[FirstContact.user_id] | |
323 | return base64.b64encode(FirstContactResp.SerializeToString()) | |
324 | elif subpath == "save_backup": | |
325 | # NOTE: This took me way longer to realize than it should have, but the base64 | |
326 | # payload you receive from client is broken due to some Android bug, where it | |
327 | # substitutes "+" symbols with a " " whitespace. | |
328 | # I don't want you to waste half hour to hours figuring out why you're getting | |
329 | # corrupted data, so you're welcome. | |
330 | Backup = EIProto.Backup() | |
331 | Backup.ParseFromString(base64.b64decode(form["data"].replace(" ", "+")) | |
332 | if Backup.game.permit_level == 0: | |
333 | print("Saved a modified Backup for next game load") | |
334 | # Modify the permit level, force offer the backup | |
335 | Backup.game.permit_level = 1 | |
336 | Backup.force_offer_backup = True | |
337 | Backup.force_backup = True | |
338 | cache[Backup.user_id] = Backup | |
339 | elif subpath == "get_periodicals": | |
340 | Periodicals = EIProto.GetPeriodicalsRequest() | |
341 | Periodicals.ParseFromString(base64.b64decode(form["data"]) | |
342 | print(Periodicals) | |
343 | else: | |
344 | print(request.form) | |
345 | return "" | |
346 | ``` | |
347 | ||
348 | Lets load up the game. Nothing interesting seems to be happening yet - lets wait until we see the "Saved a modified Backup for next game load" message | |
349 | show up in the server console. Once this shows up, restart the game - you are presented with a popup that you are offered to load a | |
350 | Backup from server. Let's accept that. | |
351 | ||
352 | Now click on your silos, you have the Pro Permit for free. | |
353 | ||
354 | Now, it goes without saying, I do not condone piracy - the future versions of this game are very much guarded against this, rightfully so. | |
355 | If you attempt this in actual game servers, this is considered fraud and IS detectable by the developer (every IAP has a receipt, logically!). | |
356 | ||
357 | This version of the game is defunct as the protocol has changed quite a bit in the years since this version and additional anticheat | |
358 | measures have been added since. You cannot transfer this status (or even purchase what you just did) from this game version to the next. | |
359 | ||
360 | ### Onto the PeriodicalsRequest | |
361 | This one is a bit more fun to delve into blindly - the proto spec wont help you much here. We will need to use our trusty RE tools again and delve into | |
362 | the game disassembly again. | |
363 | ||
364 | By public knowledge, we know there are server events for "Epic Research Sale", "Research Sale", "Drone Bonus" and "Prestige Boost". We can use this information to try | |
365 | and look at any potential leads in strings. Drone seems like a good canditate, lets look into that. | |
366 | ``` | |
367 | drone_fans2 | |
368 | drone_crash | |
369 | drone_enemy | |
370 | drone_hunter | |
371 | r_icon_drone_rewards | |
372 | b_icon_drone_boost | |
373 | drone_touch | |
374 | ei_drone_lights_green | |
375 | ei_drone_lights_red | |
376 | ei_drone_package | |
377 | ei_drone_propeller | |
378 | drone-boost | |
379 | GENEROUS DRONES | |
380 | ``` | |
381 | This looks promising, right off the bat, first strings I'd check here are `r_icon_drone_rewards`, `b_icon_drone_boost`, `drone-boost` and `GENEROUS DRONES`. | |
382 | ||
383 | I inspected all 4 of them, and when I got to final 2, I found the enum string translations used for event IDs - here they are extracted for game version 1.12.13 | |
384 | ``` | |
385 | piggy-boost (Rate piggy fills is increased.) | |
386 | piggy-cap-boost (UNLIMITED PIGGY;Gains are retained when event ends.) | |
387 | prestige-boost (PRESTIGE BOOST;Collect more soul eggs on prestige, you must prestige to take advantage of this event.) | |
388 | earnings-boost (CASH BOOST;Regular earnings are increased.) | |
389 | gift-boost (GENEROUS GIFTS;Boost applies to random gifts and video gifts.) | |
390 | drone-boost (GENEROUS DRONES;Drones will produce larger rewards.) | |
391 | epic-research-sale (EPIC RESEARCH SALE;Only applies to Epic Research.) | |
392 | vehicle-sale (VEHICLE SALE;Applies to all vehicles.) | |
393 | boost-sale (BOOST SALE;Applies to the gold price of boosts.) | |
394 | boost-duration (BOOST TIME+;Boosts last longer, you must start a boost during the event.) | |
395 | ``` | |
396 | I recall there being a few more boosts, but this is useful for getting started with compositing PeriodicalsResponse with an active running event. | |
397 | ||
398 | ### Putting together the response | |
399 | We have the enum, we have the names, descriptions, lets try to create a sample server event when the client enqueries about current server periodical events. | |
400 | ``` | |
401 | elif subpath == "get_periodicals": | |
402 | # We don't actually need the information client sends us, | |
403 | # we aren't verifying any stats about client in our server. | |
404 | CurrentPeriodicals = EIProto.PeriodicalsResponse() | |
405 | # In order to add items to a repeatable field in protobuf structure, | |
406 | # we need to call .add() method on it | |
407 | event = CurrentPeriodicals.events.events.add() | |
408 | # Refer to ei.proto - we are filling fields for EggIncEvent structure here. | |
409 | event.type = "drone-boost" | |
410 | event.multiplier = 5.00 | |
411 | event.subtitle = "Drones will produce larger rewards." | |
412 | event.identifier = "GENEROUS DRONES" | |
413 | event.seconds_remaining = 300.0 | |
414 | # Lets make it respond with a 5 minute event (this will re-arm itself when client calls | |
415 | # for get_periodicals again every 6 minutes) | |
416 | return base64.b64encode(CurrentPeriodicals.SerializeToString()) | |
417 | ``` | |
418 | ||
419 | Launch the server and observe as the client periodically calls this endpoint again, it will now receive a 5 minute 5x Drone Rewards boost on the game. | |
420 | ||
421 | ## Created the Server - What now? | |
422 | We have now created a very basic server, which appropriately responds to a first contact, misuses the game backup feature to prove a point about weaknesses of | |
423 | trusting client in server. We also created a very basic server event, which always rearms itself to never expire. | |
424 | ||
425 | What do we do next? | |
426 | ||
427 | At this point, we can start dog-fooding the project. Lets start with whatever ball game throws at us as we progress. | |
428 | ||
429 | ### Contracts | |
430 | As we progress the game and start performing prestiges, we unlock a feature called "Contracts" - but disaster strikes as we don't have any contracts we could | |
431 | accept. So far we stil see our good friends `/ei/get_periodicals` and `/ei/save_backup` hammering the server at regular intervals. | |
432 | ||
433 | When we created the periodicals response payload, you might have noticed in the protobuf message an optional field called `ContractsResponse contracts`. Lets see | |
434 | what this ContractsResponse message contains. | |
435 | ||
436 | ``` | |
437 | message ContractsResponse { | |
438 | repeated Contract contracts = 1; | |
439 | optional string warning_message = 4; | |
440 | optional double server_time = 2; | |
441 | optional uint32 max_eop = 3 [default = 1000]; | |
442 | } | |
443 | ``` | |
444 | ||
445 | Notice there being an array of Contract messages right off the bat - lets find its message structure next: | |
446 | ||
447 | ``` | |
448 | message Contract { | |
449 | optional string identifier = 1; | |
450 | optional string name = 9; | |
451 | optional string description = 10; | |
452 | optional Egg egg = 2; | |
453 | ||
454 | repeated Goal goals = 3; | |
455 | message Goal { | |
456 | optional GoalType type = 1; | |
457 | optional double target_amount = 2; | |
458 | optional RewardType reward_type = 3; | |
459 | optional string reward_sub_type = 4; | |
460 | optional double reward_amount = 5; | |
461 | optional double target_soul_eggs = 6; | |
462 | } | |
463 | ||
464 | repeated GoalSet goal_sets = 16; | |
465 | message GoalSet { | |
466 | repeated Goal goals = 1; | |
467 | } | |
468 | ||
469 | optional bool coop_allowed = 4; | |
470 | optional uint32 max_coop_size = 5; | |
471 | optional uint32 max_boosts = 12; | |
472 | optional double minutes_per_token = 15 [default = 60]; | |
473 | optional double expiration_time = 6; | |
474 | optional double length_seconds = 7; | |
475 | optional double max_soul_eggs = 13; | |
476 | optional uint32 min_client_version = 14; | |
477 | optional bool debug = 11; | |
478 | } | |
479 | ``` | |
480 | ||
481 | We will need to do a bit of reading. Fortunately, the game has a community wiki, lets look into how contracts should work. I took an | |
482 | [older revision of Contracts wiki page from 2021](https://egg-inc.fandom.com/wiki/Contracts?oldid=13015) and did some slight research. | |
483 | ||
484 | From what I gather, at one point, there was only one set of contract rewards, shared between everyone - then they created a system where | |
485 | beginners will get easier contract goals while more advanced players get harder contract goals. | |
486 | ||
487 | We can put two-and-two together here and infer that `repeated Goal goals` is the legacy contract system - where everyone was on equal footing | |
488 | and `repeated GoalSet goal_sets` is the *new* goal system that is split into Standard and Elite. | |
489 | ||
490 | We also learn that in future game version, they completely reworked how contracts work *yet* again into a grading "bracket" system. Fortunately, | |
491 | we do not have to worry about that in our current target revision. | |
492 | ||
493 | Now to get the ball rolling, there is conveniently a starting point set ahead for us already. The developer of game intended to ease new players into | |
494 | contracts by creating a simple & easy contract called [Your First Contract](https://egg-inc.fandom.com/wiki/Contracts/Your_First_Contract?oldid=13547). | |
495 | ||
496 | This page tells us all the information we need to compose our first Contract, so lets try to make one. | |
497 | ||
498 | ``` | |
499 | elif subpath == "get_periodicals": | |
500 | # We don't actually need the information client sends us, | |
501 | # we aren't verifying any stats about client in our server. | |
502 | CurrentPeriodicals = EIProto.PeriodicalsResponse() | |
503 | # [...] | |
504 | Contract = CurrentPeriodicals.contracts.contracts.add() | |
505 | Contract.identifier = "first-contract" | |
506 | Contract.name = "Your First Contract" | |
507 | Contract.description = "We heard you are open to contract work! Help fill this order from the local pharmacy!" | |
508 | Contract.egg = EIProto.Egg.MEDICAL | |
509 | Contract.coop_allowed = False | |
510 | Contract.minutes_per_token = 5 | |
511 | # Lets set expiry time to always be 3 days into future | |
512 | Contract.expiration_time = time.time() + (3600.0 * 72.0) | |
513 | Contract.length_seconds = 3600.0 * 4.0 | |
514 | # The wiki mentions that you cannot get this contract after you reach 5000 Soul Eggs | |
515 | Contract.max_soul_eggs = 5000.0 | |
516 | # We should have the basic metadata set now, lets create the goalsets. | |
517 | FirstSet = Contract.goal_sets.add() | |
518 | Goal = FirstSet.goals.add() | |
519 | # There is only one type of goal in this verison | |
520 | Goal.type = EIProto.GoalType.EGGS_LAID | |
521 | Goal.target_amount = 100000.0 | |
522 | Goal.reward_type = EIProto.RewardType.GOLD | |
523 | Goal.reward_amount = 192 | |
524 | Goal = FirstSet.goals.add() | |
525 | Goal.type = EIProto.GoalType.EGGS_LAID | |
526 | Goal.target_amount = 500000000.0 | |
527 | Goal.reward_type = EIProto.RewardType.PIGGY_FILL | |
528 | Goal.reward_amount = 10000 | |
529 | # Lets now add the Elite table, we can pretty much copy-paste the above here. | |
530 | SecondSet = Contract.goal_sets.add() | |
531 | Goal = SecondSet.goals.add() | |
532 | Goal.type = EIProto.GoalType.EGGS_LAID | |
533 | Goal.target_amount = 100000.0 | |
534 | Goal.reward_type = EIProto.RewardType.GOLD | |
535 | Goal.reward_amount = 500 | |
536 | Goal = SecondSet.goals.add() | |
537 | Goal.type = EIProto.GoalType.EGGS_LAID | |
538 | Goal.target_amount = 500000000.0 | |
539 | Goal.reward_type = EIProto.RewardType.PIGGY_FILL | |
540 | Goal.reward_amount = 10000 | |
541 | return base64.b64encode(CurrentPeriodicals.SerializeToString()) | |
542 | ``` | |
543 | ||
544 | Lets try that out in-game now - after waiting for a minute, we see our contract prop up, but I immediately noticed one thing amiss. | |
545 | The contract goals are swapped! I am getting Elite contract rewards for a Standard contract. | |
546 | ||
547 | This piece of information now tells us that the first entry in GoalSets refers to Elite rewards and the second entry in GoalSets to Standard rewards. | |
548 | After swapping the sets around, we now see a contract with the corrected rewards. | |
549 | ||
550 | I playtested it a bit and the contract worked as expected. | |
551 | ||
552 | Now, the above code could be a lot neater. For your homework, if you're not skipping to the public source release in the end, you should try to create | |
553 | a contract database and try scheduling them like the game originally did - a "Leggacy" contract every Friday and regular contracts showing up every 1-2 weeks | |
554 | for roughly 2 weeks. | |
555 | ||
556 | ## Conclusion so far | |
557 | We have created a (rather ugly looking) server emulator for the game. It functions, but it needs a lot of work still before we can call it ready. | |
558 | If you have followed this far, give yourself pat on the back - if you actually tried to run this code, give yourself an extra pat on the back. | |
559 | ||
560 | Before I give you the public source to the project, you might want to try your hand at creating a few more things. | |
561 | - "Cloud" save, present a Backup to any new device that just started playing. | |
562 | - Contracts Database and scheduler | |
563 | - Server Event scheduler | |
564 | ||
565 | I apologize if my method of documenting this has been messy, but that's also part of the chaos of reverse engineering, you are constantly learning new things | |
566 | about the project you are currently doing - refactoring becomes an essential part once you have documented the protocol to a comfortable degree. | |
567 | ||
568 | I won't give any promises for a part 2 any time soon, but I will be trying to make this feature complete, so without further ado, here are the git repository links: | |
569 | [github.com](https://github.com/cernodile/reEgg), [git.based.quest](https://git.based.quest/?p=reEgg.git;a=tree;h=refs/heads/master;hb=refs/heads/master). | |
570 | ||
571 | Thank you for reading and making it all the way to the end, | |
572 | - Cernodile |