~jakob/blog

ref: 97f55e55230ab9472877341fcf406f1ebe428eee blog/org/Browser Games Aren't an Easy Target/browser-games-aren-t-an-easy-target.org -rw-r--r-- 25.7 KiB View raw
97f55e55 — Jakob L. Kreuze Add article "Browser Games Aren't an Easy Target". 2 months ago
                                                                                
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
#+TITLE: Browser Games Aren't an Easy Target
#+DATE: <2020-01-10 Fri 18:39>
#+TAGS: writeup, programming, reverse-engineering, video-games, game-hacking, javascript

If you're about my age and had a similarly dull upbringing, you probably also
have memories of playing video games behind a teacher's back whenever class
involved going to some sort of "computer lab." Flash games were the thing when I
was in elementary school, and when I was in middle school, I'd bring Quake with
me on a flash drive. By the time I was in high school, I'd realized that these
opportunities were better spent getting a head start on homework for other
classes, but I did have a few friends who still passed the time playing video
games. Rather than Flash games or Quake, though, these were browser games using
the new-fangled HTML5 canvas. I'd practically forgotten these games existed
until someone from my capture-the-flag team mentioned "krunker.io". [[http://iogames.space/][Apparently]]
it's one of the more popular ones. It got me thinking about how I'd go about
writing cheats for a game in the browser. Writing cheats for CS:GO was a breeze,
so why would this be any harder? I had some time to spare over winter break, so
I decided to give it a go and see what kind of damage I could do.

* Reconnaissance

[[https://mitmproxy.org/][mitmproxy]] was pretty much the only tool I used in this project. The first thing
I did was hook my browser up to it and load the game to see what kinds of
requests it made.[fn:1]

[[./mitmproxy-initial.png]]

I've truncated it here because the flows that came afterwards were just assets.
Right off the bat, we're learning quite a bit about the game's infrastructure.
Namely that it has three parts:

1. 'krunker.io', the URL you type into your browser, which is where the code is.
2. 'assets.krunker.io', for serving up textures and models.
3. 'matchmaker.krunker.io', which appears to be some sort of REST API for
   finding a game.

I'm sure that reversing the Matchmaking API would be fun, but there's more fun
to be had in messing with the code.

** Code

We can see from the flow that the following libraries are loaded:

- 'jquery-3.2.1.min.js'
- 'jquery-ui.js'
- 'howler.min.js'
- 'Tween.min.js'
- 'nipplejs.min.js'
- 'zip.js'
- 'zip-ext.js'

We all know what jQuery is. After consulting the all-mighty search engine of the
interwebs, I figured out that [[https://howlerjs.com/][howler.js]] is an audio library, that [[https://github.com/tweenjs/tween.js/][tween.js]] is a
library for animations, and that [[https://yoannmoi.net/nipplejs/][nippleJS]] is a virtual joystick for mobile
devices.[fn:2] I was tripped up by 'zip.js' and 'zip-ext.js' initially, thinking
that these are where the code is, but it didn't take long to realize that these
constitute the [[https://gildas-lormeau.github.io/zip.js/][zip.js]] library for working with ZIP files. Well, if the game code
isn't in any of those files, then it has to be inlined in a =<script>= tag
somewhere. A quick =grep= reveals that it is, indeed, at the bottom of the page.

* Injecting Code

This is where one of the more interesting features of mitmproxy comes in. I
split the page's HTML into three files: one containing everything leading up to
the script, one containing the script itself, and one containing everything that
follows the script. Then, I whipped up a quick [[https://docs.mitmproxy.org/stable/addons-overview/][addon]] for piecing those together
on the fly when we see a request for 'krunker.io'.

#+BEGIN_SRC python
from mitmproxy import ctx

class Replacer:
    def __init__(self):
        with open("header.html") as f:
            self.header = f.read()
        with open("game.js") as f:
            self.game = f.read()
        with open("footer.html") as f:
            self.footer = f.read()

    def response(self, flow):
        if flow.request.host == "krunker.io" and flow.request.path == "/":
            flow.response.set_text(self.header + self.game + self.footer)

addons = [
    Replacer()
]
#+END_SRC

With an environment set up for experimenting, it was time to get into it. I ran
[[https://beautifier.io/][beautifier.io]] on 'game.js' and began to peer around for strings of note. There's
a massive base64 blob in the middle of the file that stood out like a sore
thumb, so I decoded it to see what it was.

#+BEGIN_SRC prog
jakob@Epsilon ~ $ base64 -d < /tmp/extracted.b64 | file -
/dev/stdin: WebAssembly (wasm) binary module version 0x1 (MVP)
#+END_SRC

Whoa, holy shit! This is the first time I'm seeing WebAssembly in the wild!

Thinking I'd want to modify the blob, I extracted it to a file and modified the
addon slightly.

#+BEGIN_SRC python
from base64 import b64encode

from mitmproxy import ctx

class Replacer:
    def __init__(self):
        with open("header.html") as f:
            self.header = f.read()
        with open("game.js") as f, open("krunker.wasm", "rb") as g:
            self.game = f.read().replace("[REPLACE ME]", b64encode(g.read()).decode())
        with open("footer.html") as f:
            self.footer = f.read()

    def response(self, flow):
        if flow.request.host == "krunker.io" and flow.request.path == "/":
            flow.response.set_text(self.header + self.game + self.footer)

addons = [
    Replacer()
]
#+END_SRC

Now, in addition to stringing together our three files, we're also reading in
'krunker.wasm', the binary version of the base64 blob, encoding it to base64,
and splicing it to the script where I'd substituted the original base64 blob
with "[REPLACE ME]".

Running =strings= on the wasm file reveals a couple of things. First, this part of
the game is written in Rust.

#+BEGIN_SRC prog
/rustc/73528e339aae0f17a15ffa49a8ac608f50c6cf14/src/libstd/io/impls.rs
TLS Context not set. This is a rustc bug. Please file an issue on https://github.com/rust-lang/rust.
attempt to calculate the remainder with a divisor of zero
#+END_SRC

And, second, we're going to be met with some resistance.

#+BEGIN_SRC prog
getElementsByTagNameCould not get elementsscriptpatchControlpatchPlayerspatchOnTickpatchOnKeyPressedpatchForAimbotDetected injected scriptHACKER
...
validateEvalUnmodifiedCould not set global validateEvalUnmodifiedCould not get eval functionwindow.validateEvalUnmodified("");  // Ahoy, haxor kiddies! 
Eval is tampered; preventing execution
#+END_SRC

I cracked open the Firefox debugger and found that the game wasn't spending a
lot of time in the WebAssembly component. Instead, there was a pseudo-file
called "SOURCE" that I could find no reference to. My first hypothesis was that
the code was zipped, so I tried stubbing out the methods of 'zip.js'.

#+BEGIN_SRC javascript
r.zip = {
  Reader: function () {
    console.log("Reader")
  },
  Writer: function () {
    console.log("Writer")
  },
  BlobReader: function () {
    console.log("BlobReade")
  },
  Data64URIReader: function () {
    console.log("Data64URIReader")
  },
  TextReader: function () {
    console.log("TextReader")
  },
  BlobWriter: function () {
    console.log("BlobWriter")
  },
  Data64URIWriter: function () {
    console.log("Data64URIWriter")
  },
  TextWriter: function () {
    console.log("TextWriter")
  },
  createReader: function () {
    console.log("createReader")
  },
  createWriter: function () {
    console.log("createWriter")
  },
}
#+END_SRC

But the game loaded fine. So I went back to the debugger and placed some
breakpoints around where the WebAssembly module is loaded, realizing that its
only purpose is to deobfuscate the JavaScript that eventually makes it into that
"SOURCE" pseudo-file I saw earlier. I figured this out by hooking the
=getStringFromWasm= function in 'game.js' and looking for any sort of JavaScript
code. I extracted these to files so I could beautify and inspect them. The
contents of "SOURCE" were in the second of these.

My first idea was to get rid of the WebAssembly and just inject the loaded
JavaScript instead. 

#+BEGIN_SRC python
with open("game.js") as f, open("krunker.wasm", "rb") as g, open("extracted.2.js") as h:
    # self.game = f.read().replace("[REPLACE ME]", b64encode(g.read()).decode())
    self.game = h.read()
#+END_SRC

But this breaks the game horribly. Inspecting the call stack in the debugger, I
found that the code in "SOURCE" is referenced twice. The second time being from
=__wbg_newwithargs_10def9c4239ab893=, which looks like

#+BEGIN_SRC javascript
imports.wbg.__wbg_newwithargs_10def9c4239ab893 = function (A, g, Q, B) {
  return addHeapObject(new Function(getStringFromWasm(A, g), getStringFromWasm(Q, B)))
},
#+END_SRC

I thought that the code might have been injected by an =eval= or by adding a
=script= element to the document, but here it's using JavaScript's =Function=
constructor -- a feature I had no idea existed.

In my initial attempt to get a hold of the code, I encoded my extracted version
of "SOURCE" as base64, introduced a string called =replacement_code=, and replaced
the implementation of =__wbg_newwithargs_10def9c4239ab893= with this:

#+BEGIN_SRC javascript
// return addHeapObject(new Function(getStringFromWasm(A, g), getStringFromWasm(Q, B)))
return addHeapObject(new Function(getStringFromWasm(A, g), atob(replacement_code)));
#+END_SRC

This didn't work. So I added in some debug prints and realized that the function
wasn't being called with the same arguments every time (which should be
unsurprising).

Here was my second attempt:

#+BEGIN_SRC javascript
if (getStringFromWasm(Q, B).startsWith("!")) {
  console.log("Injecting code...")
  return addHeapObject(new Function(getStringFromWasm(A, g), atob(replacement_code)));
}
return addHeapObject(new Function(getStringFromWasm(A, g), getStringFromWasm(Q, B)));
#+END_SRC

This didn't work either. If we run the game twice and inspect the value of
=getStringFromWasm(A, g)= (the argument list), it clearly isn't the same both
times. So I decided to see if the function code was different, too.

#+BEGIN_SRC prog
jakob@Epsilon ~/ $ radiff2 -c {1,2}.js
File size differs 2462470 vs 2461765
Buffer truncated to 2461765 byte(s) (705 not compared)
86611
#+END_SRC

So... the WebAssembly is essentially generating JavaScript on-the-fly. Of
course, all renditions of the code do the same thing, but the variable names are
changing. My first thought was to stub out all of the nondeterministic imports
like =__wbg_random_09364f2d8647f133=, but this just broke things. Attempt three
was to see if I could add something to the function code and have it still work.

#+BEGIN_SRC javascript
return addHeapObject(new Function(getStringFromWasm(A, g), getStringFromWasm(Q, B) + " console.log('hello, world!'"));
#+END_SRC

The game didn't load this time, either. Strange, but then I remembered the
rather hostile strings in 'krunker.wasm' and decided to see what was happening
in the debugger. I hooked =__wbg_newwithargs_10def9c4239ab893= so that when the
=Function= object is created, that reference is saved to a global called
=the_function=. Then, I hooked =getObject=, the JavaScript glue for getting
something from the heap from WebAssembly, and if the object being returned is
=the_function=, I trip a breakpoint. The first time the function is referred to is
in =__wbg_toString_c663742ecc5b25ea=. If we've tampered with it, it goes straight
to =__wbindgen_object_drop_ref=. Otherwise, it goes to =__wbg_call_04d7c0ad06df27c9=
before being freed. So it seems the WebAssembly module is doing some sort of
tampering checking, checking the source code of the resultant =Function= to ensure
that we haven't hooked the =Function= constructor or anything like that. This is
actually pretty easy to get around. We could

1. Hook =__wbg_toString_c663742ecc5b25ea= to return a fake value when it's trying
   to get the source code of the =Function=.
2. Hook =__wbg_call_04d7c0ad06df27c9= and pull an [[https://www.youtube.com/watch?v=mC1ikwQ5Zgc][Indiana Jones]], calling a
   different function.

I went with the latter. To summarize the game plan, we'll hook the place where
the =Function= is constructed, get a reference to that =Function= object, hook the
place where it's called, and if it's the same reference we got before, call a
=Function= object of our own creation instead. Here's what my patch looks like:

#+BEGIN_SRC javascript
imports.wbg.__wbg_newwithargs_10def9c4239ab893 = function (A, g, Q, B) {
  if (getStringFromWasm(Q, B).startsWith("!")) {
    console.log("[PATCH] Got reference to game code function!");
    console.log("[PATCH] Code path: __wbg_newwithargs_10def9c4239ab893")
    the_function = new Function(getStringFromWasm(A, g), getStringFromWasm(Q, B));
    my_function = new Function(getStringFromWasm(A, g), "console.log('Successfully hooked!');" + getStringFromWasm(Q, B));
    return addHeapObject(the_function);
  }
  return addHeapObject(new Function(getStringFromWasm(A, g), getStringFromWasm(Q, B)))
},
...
imports.wbg.__wbg_call_04d7c0ad06df27c9 = function (A, g, Q, B, I) {
  try {
    const target = getObject(A);
    if (target === the_function) {
      console.log("[PATCH] Preparing to Indiana Jones that shit...")
      return addHeapObject(my_function
                           .call(getObject(g), getObject(Q), getObject(B), getObject(I)))
    }
    return addHeapObject(getObject(A)
                         .call(getObject(g), getObject(Q), getObject(B), getObject(I)))
  } catch (A) {
    handleError(A)
  }
},
#+END_SRC

#+BEGIN_SRC prog
...
[PATCH] Got reference to game code function!
[PATCH] Code path: __wbg_newwithargs_10def9c4239ab893
[PATCH] Preparing to Indiana Jones that shit...
Successfully hooked!
...
#+END_SRC

This time, our modification of the code worked. Nice! With that, we can address
one quality-of-life issue that was starting to get on my nerves.

#+BEGIN_SRC python
from base64 import b64encode
from os.path import getmtime

from mitmproxy import ctx

def create_page(header, prefix, game, wasm, footer):
    return "".join([
        header,
        "let replacement_code=\"", b64encode(prefix).decode(), "\";",
        game.replace("[REPLACE ME]", b64encode(wasm).decode()),
        footer
    ])

class Replacer:
    def __init__(self):
        self.most_recent_update = 0
        self.check_for_updates()

    def check_for_updates(self):
        for filename in ["header.html", "extracted.2.js", "game.js", "krunker.wasm", "footer.html"]:
            if getmtime(filename) > self.most_recent_update:
                self.most_recent_update = getmtime(filename)
                self.update_replacement()
                print("Updating files...")

    def update_replacement(self):
        with open("header.html") as f:
            self.header = f.read()
        with open("extracted.2.js", "rb") as f:
            self.prefix = f.read()
        with open("game.js") as f, open("krunker.wasm", "rb") as g:
            self.game = f.read()
            self.wasm = g.read()
        with open("footer.html") as f:
            self.footer = f.read()

        self.replacement = create_page(
            self.header,
            self.prefix,
            self.game,
            self.wasm,
            self.footer
        )        

    def response(self, flow):
        self.check_for_updates()
        if flow.request.host == "krunker.io" and (flow.request.path == "/" or flow.request.path.startswith("/?game=")):
            flow.response.set_text(self.replacement)

addons = [
    Replacer()
]
#+END_SRC

# Footnote: I'd like to experiment with parsing it into an AST and walking that.

Because the names of references are generated at runtime, we can't really just
substitute in our own code string. We have to modify the string that's generated
by the WebAssembly module. We'll have to find something worth changing before we
can do that, though, so I began to read through the generated source code. The
first thing that stood out to me was an array of weapon definitions. Here's how
the sniper rifle is defined:

#+BEGIN_SRC javascript
{
  'name': 'Sniper Rifle',
  'src': 'weapon_1',
  'icon': 'icon_1',
  'sound': 'weapon_1',
  'animWhileAim': !0x0,
  'trail': !0x0,
  'flap': {
    'src': 'flap_0',
    'rot': 2.1,
    'scl': 0x1,
    'zOff': 0.43,
    'xOff': 0.17,
    'yOff': 0.53
  },
  'noAo': !0x0,
  'VuFlFKJOHFGfinUeccOKbaQQPyhjvfYD': !0x0,
  'type': 0x0,
  'scope': !0x0,
  'swapTime': 0x12c,
  'aimSpeed': 0x78,
  'spdMlt': 0.95,
  'ammo': 0x3,
  'reload': 0x5dc,
  'dmg': 0x64,
  'pierce': 0.2,
  'range': 0x3e8,
  'dropStart': 0xe6,
  'dmgDrop': 0x1e,
  'scale': 0.00115608717587935,
  'leftHoldY': -0.7,
  'rightHoldY': -0.75,
  'leftHoldZ': 2.4,
  'rightHoldZ': 0.4,
  'xOff': 0.8,
  'yOff': -0.68,
  'zOff': -1.8,
  'xOrg': 0x0,
  'yOrg': -0.55,
  'zOrg': -0.8,
  'cLean': 0.2,
  'cRot': 0.2,
  'cDrop': 0.1,
  'inspectR': 0.2,
  'inspectM': 0.1,
  'muzOff': 0x8,
  'muzMlt': 1.6,
  'rate': 0x384,
  'spread': 0x104,
  'zoom': 2.7,
  'leanMlt': 1.5,
  'recoil': 0.009,
  'recoilR': 0.02,
  'recover': 0.993,
  'recoverY': 0.997,
  'recoverF': 0.975,
  'recoilYM': 0.35,
  'recoilZ': 1.4,
  'recoilAnim': {
    'time': 0x118,
    'aimTime': 0x1f4,
    'recoilTweenY': 0.3
  },
  'jumpYM': 0.15,
  'rumble': 0.9,
  'rumbleDur': 0x1f4,
  'icnPad': 0x9
}
#+END_SRC

Some parts of it are obfuscated,[fn:4] but some aren't. My first attempt at a
cheat was to set all of the =recoil= and =spread= values to zero.

#+BEGIN_SRC javascript
code = code.replace(/('recoil\w*?':)[0-9\x\. ]*?,/g, function(match, p1, offset, string) {
  console.log(match);
  return p1 + "0.000,";
});
code = code.replace(/('spread':)[0-9\x\. ]*?,/g, function(match, p1, offset, string) {
  console.log(match);
  return p1 + "0x0,";
});
#+END_SRC

This seemed to work until I actually tried shooting people and realized that my
bullets weren't hitting anything, which made me suspect that the server was
responsible for taking the spread into account.

I also came across the definitions for the game's "classes".

#+BEGIN_SRC javascript
{
  'name': 'Triggerman',
  'loadout': [0x1],
  'secondary': !0x0,
  'colors': [0xa77860, 0x3d3d3d, 0x232323, 0x282828, 0x6c5042, 0xbfbfbf],
  'health': 0x64,
  'segs': 0x6,
  'speed': 1.05
}
#+END_SRC

So I tried setting =speed= to something absurdly high, but after a few seconds of
moving forward I'd be teleported back. Again, it seems the server is also
calculating my movement and realizing that something's wrong.

Rather than find which values are truly client-side and which are verified
server-side, I decided to implement the bread and butter of client-side cheats:
a wallhack. My thinking was that the easy way to go about this would be to patch
every call to =gl.depthFunc= and set the =func= parameter to =gl.ALWAYS=.

#+BEGIN_SRC javascript
code = code.replace(/\['depthFunc'\]\(0x\d\d\d\)/g, function(match, p1, offset, string) {
  return "['depthFunc'](0x207)";
});
#+END_SRC

This actually worked, but not in a way that's helpful for getting an advantage
in the game. So I had to be a bit more clever. Looking through 'extracted.2.js',
it's pretty obvious that they're using [[https://threejs.org/][three.js]], and from experience, I know
that when it comes to rendering something 2D over a three.js scene, most people
opt for some sort of overlay. So I did a search for 'game-overlay', and found
that it occurs only once.

#+BEGIN_SRC javascript
function (czO, czP, czQ) {
  let czR = czQ(0x7),
      czS = czQ(0x15),
      czT = czQ(0x8),
      czU = czQ(0x4),
      czV = {};
  var czW;
  let czX = czV['canvas'] = document['getElementById']('game-overlay');

  ...

  czV['render'] = function (czO, czP, czQ, czU, czY) {
    let cA3 = czV,
        cAs = czX['width'] / czO,
        cAt = czX['height'] / czO,
        cAu = 'none' == menuHolder['style']['display'] && 'none' == endUI['style']['display'] && 'none' == killCardHolder['style']['display'],
        cAv = czQ['camera']['OAyrBAIOyFXMWtKxEfkVjBvqsgcYuyWi']();
    ...
    for (cAw = 0x0; cAw < czP['players']['list']['length']; ++cAw) {
      if (!(czW = czP['players']['list'][cAw])['active']) continue;
      if (czW['aeWOplgwNeuXsCSinrkfFWfJBNPqqMsp'] || !czW['eaXYenBVjWrAqKUShuRgPGpSwPVbhVHm']) continue;
      if (!czW['igkTahukFkFIrcwuUsvtqgPJfhPajghp']) continue;
      if ((cAI = czW['eaXYenBVjWrAqKUShuRgPGpSwPVbhVHm']['position']['clone']())['y'] += czR['bSnWGqqv'] + czR['nameOffset'] - czW['crouchVal'] * czR['crouchDst'], 0x0 <= czW['hatIndex'] && (cAI['y'] += czR['nameOffsetHat']), !(0x1 <= 0x14 * (cAJ = Math['max'](0.3, 0x1 - czT['SHAokGxkzQABudAEqJwdYyVzJPmwCsxg'](cAv['x'], cAv['y'], cAv['z'], cAI['x'], cAI['y'], cAI['z']) / 0x258)) && czQ['frustum']['containsPoint'](cAI))) continue;
      cAb['save'](), cAI['project'](czQ['camera']), cAI['x'] = (cAI['x'] + 0x1) / 0x2, cAI['y'] = (cAI['y'] + 0x1) / 0x2, cAb['translate'](cAs * cAI['x'], cAt * (0x1 - cAI['y'])), cAb['scale'](cAJ, cAJ);
      let czO = 0x78,
          czX = 0x1 == czV['nametagStyle'] ? 0x6 : 0x10;
      if (0x0 == czV['nametagStyle'] || 0x3 == czV['nametagStyle']) {
        cAb['fillStyle'] = 'rgba(0, 0, 0, 0.4)', cAb['fillRect'](-0x3c, -czX, czO, czX), cA3['dynamicHP'] && czW['hpChase'] > czW['health'] / czW['pAblSevloQuKmtUpAKdXIHpqBTWHCbRR'] && (cAb['fillStyle'] = '#FFFFFF', cAb['fillRect'](-0x3c, -czX, czO * czW['hpChase'], czX));
        var cAA = czU && czU['team'] ? czU['team'] : window['spectating'] ? 0x1 : 0x0;
        cAb['fillStyle'] = cAA == czW['team'] ? czS['teams'][0x0] : czS['teams'][0x1], cAb['fillRect'](-0x3c, -czX, czO * (czW['health'] / czW['pAblSevloQuKmtUpAKdXIHpqBTWHCbRR']), czX);
      }
      if (0x3 > czV['nametagStyle']) {
        let czO = czW['name'],
            czP = czW['clan'] ? '[' + czW['clan'] + ']' : null,
            czQ = czW['level'];
        cAb['font'] = '30px GameFont';
        let czT = czQ && 0x1 != czV['nametagStyle'] ? cAb['measureText'](czQ)['width'] + 0xa : 0x0;
        cAb['font'] = '20px GameFont';
        let czU = cAb['measureText'](czO)['width'] + (czP ? 0x5 : 0x0),
            czY = czT + czU + (czP ? cAb['measureText'](czP)['width'] : 0x0);
        cAb['translate'](0x0, -czX - 0xa), cAb['fillStyle'] = 'white', cAb['font'] = '30px GameFont', czQ && 0x1 != czV['nametagStyle'] && cAb['fillText'](czQ, -czY / 0x2, 0x0), cAb['font'] = '20px GameFont', cAb['globalAlpha'] = 0x1, cAb['fillText'](czO, -czY / 0x2 + czT, 0x0), cAb['globalAlpha'] = 0x0 <= czR['verClans']['indexOf'](czW['clan']) ? 0x1 : 0.4, cAb['fillStyle'] = 0x0 <= czR['verClans']['indexOf'](czW['clan']) ? czS['verified']['clan'] : 'white', czP && cAb['fillText'](czP, -czY / 0x2 + czT + czU, 0x0);
      }
      cAb['restore']();
    }
    ...
#+END_SRC

Not the most readable snippet, but there are a few things that stand out to me.
Namely, iterating over the =players= list and rendering something using the =health=
attribute. Those four =if= statements seem to be checking if the player is visible
(given away by the =czQ['frustum']['containsPoint'](cAI)=), so what if we just
patch out the =continue='s?

#+BEGIN_SRC javascript
code = code.replace(/if\(\!\(czW=czP\['players'].*cAI\)\)\)continue;/, function(match, p1, offset, string) {
  return "try{" + match.replace(/continue/g, "true") + "}catch(e){continue;}";
});
#+END_SRC

I pulled a professional programmer move and wrapped everything in a =try=, =catch=
block because the game would freeze up without it, but this works pretty well.

#+BEGIN_EXPORT html
<iframe width="560" height="315" sandbox="allow-same-origin allow-scripts" src="https://toobnix.org/videos/embed/b1b1b1cb-7644-4551-87d0-84723792a179" frameborder="0" allowfullscreen="1"></iframe>
#+END_EXPORT

And, hey! We have a wallhack! This probably isn't representative of /all/ browser
games -- I'd expect most to be easier to screw with -- but I thought that the
obfuscation and anti-cheat measures here made for a worthy opponent. It doesn't
stack up against [[https://vmcall.blog/battleye-stack-walking/][BattlEye]], but this did take me more than an afternoon to figure
out. To that effect, nice work, Sidney!

#+BEGIN_EXPORT html
<blockquote class="twitter-tweet" data-lang="en"><p lang="en" dir="ltr">New Rotation map and Anti Cheat tomorrow bois</p>&mdash; Sidney (@Sidney_de_Vries) <a href="https://twitter.com/Sidney_de_Vries/status/1190593818233425920?ref_src=twsrc%5Etfw">November 2, 2019</a></blockquote>
<script async src="https://platform.twitter.com/widgets.js" charset="utf-8"></script>
#+END_EXPORT

... 

Even if your new "Anti Cheat" was this :)

#+BEGIN_EXPORT html
<div class="mastodon">
    <iframe height="180" src="https://mastodon.sdf.org/@jakob/103442557410442316/embed"></iframe>
</div>
#+END_EXPORT

-----

I'm inevitably going to get flack for cheating in a video game. Before you write
me an email, understand that I really don't care. I have a lot more fun reverse
engineering games and writing cheats for them than I do playing them. If it
makes you feel any better, the only time these cheats see any use is when I'm
demonstrating them. Peace out.

[fn:1] I didn't notice the request for '/textures/recticle.png' until I was editing this. Sheesh, that's an unfortunate typo.
[fn:2] Given that the author is a self-proclaimed "JavaScript and NodeJS developer", I'm not particularly surprised by the embarrassingly childish name. Go ahead, bud. Put that on your CV.
[fn:4] Identifiers like ='VuFlFKJOHFGfinUeccOKbaQQPyhjvfYD'= are pretty common in the code. I suspect these are the high-stakes variables that people like me would be grepping for.

# ** Matchmaking

# GET https://matchmaker.krunker.io/generate-token
# referer/origin: https://krunker.io

# > Response with 
# {
#     "input": "[TOKEN]"
# }


# GET https://matchmaker.krunker.io/ping-list?hostname=krunker.io
# referer/origin: https://krunker.io

# > Response with 
# {
#     "[server-name]": "[address]"
# }

# GET https://matchmaker.krunker.io/seek-game
# referer/origin: https://krunker.io
# GET PARAMS: hostname=krunker.io, region=[REGION], autoChangeGame=false, validationToken=[TOKEN], dataQuery={"v":"Q43rG"]}

# > Response with 
# {
#     "changeReason": null,
#     "clientID": "feb0c9f1-128a-4993-a381-bbf7a56318da",
#     "gameId": "NY:dvn7x",
#     "host": "[address]",
#     "port": "[port]"
# }

# GET https://matchmaker.krunker.io/game-info?game=[id]
# referer/origin: https://krunker.io

# > Response with info about the game.