Repository navigation
Expand file tree
/
Copy pathapp.py
More file actions
1758 lines (1488 loc) · 78.5 KB
/
Copy pathapp.py
File metadata and controls
1758 lines (1488 loc) · 78.5 KB
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
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
#!/usr/bin/env python3.13
"""
vulnerable-python - an intentionally vulnerable demo app for EyalSec.
############################################################################
# WARNING: THIS APPLICATION IS DELIBERATELY, DANGEROUSLY INSECURE. #
# Every endpoint contains a real, exploitable vulnerability by design. #
# Run it ONLY on an isolated lab machine you control. Never expose it to #
# the internet and never deploy it to production. #
############################################################################
Why this exists
---------------
EyalSec ships `es-python`, a security-instrumented Python runtime that tracks
untrusted data from *sources* to dangerous *sinks* as the program runs.
Detection runs inside the program, not beside it. This app is a live target
for it.
Run this exact file two ways:
* Under plain python3 -> it is a normal, silently exploitable web app.
Nothing is logged. The vulnerability just works.
* Under es-python -> every attack that reaches a sink is reported to
the EyalSec dashboard (Observe mode), or aborted
with a RuntimeError at the sink (Protect mode,
ES2_RAISE=1). The code did not change - the
runtime did.
The code below is plain standard-library Python. It is byte-for-byte identical
regardless of which runtime runs it - that is the whole point. EyalSec adds
zero source changes to the app it protects.
Each route demonstrates exactly one (taint source -> sink) pair. See the
ENDPOINTS catalog at the bottom, which also drives the landing page at "/".
"""
import builtins
import csv
import ctypes
import importlib
import importlib.machinery
import importlib.util
import io
import logging
import marshal
import mmap
import os
import pickle
import random
import re
import socket
import sqlite3
import ssl
import subprocess
import sys
import tempfile
import urllib.request
import xml.etree.ElementTree as ET
from flask import Flask, Response, request
import demo_credentials
import foreign_lab
import landing
app = Flask(__name__)
HERE = os.path.dirname(os.path.abspath(__file__))
DB_PATH = os.path.join(HERE, "vulnerable_demo.db")
SAMPLE_DIR = os.path.join(HERE, "sample")
# A fake secret the format-string sink can leak, so the demo shows real impact.
APP_SECRETS = {"api_key": "sk-demo-DO-NOT-USE-3f9a2c7b41e0", "flag": "EYALSEC{format_string_leak}"}
# --------------------------------------------------------------------------- #
# One-time fixtures: a seeded SQLite DB, a file the "file" source reads, and a
# world-writable module the "foreign-code" source flags.
# --------------------------------------------------------------------------- #
def setup_fixtures():
db = sqlite3.connect(DB_PATH)
db.executescript(
"""
DROP TABLE IF EXISTS users;
CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT, bio TEXT, secret TEXT);
INSERT INTO users (name, bio, secret) VALUES
('alice', 'demo user', 'alice-password-hunter2'),
('bob', 'demo user', 'bob-api-token-abc123'),
('admin', 'superuser', 'admin-root-key-topsecret');
"""
)
db.commit()
db.close()
os.makedirs(SAMPLE_DIR, exist_ok=True)
# The "file" taint source: an on-disk file whose contents an attacker who
# can write the file controls. Its bytes flow into a SQL query below.
with open(os.path.join(SAMPLE_DIR, "tainted_input.txt"), "w") as fh:
fh.write("alice' OR '1'='1")
# A benign readable file for the path-traversal demo's default case.
with open(os.path.join(SAMPLE_DIR, "notes.txt"), "w") as fh:
fh.write("public demo note - nothing secret here\n")
# A secret that lives OUTSIDE the sample/ sandbox the /path route is meant
# to serve. Reading it via ../ proves the traversal escaped the sandbox,
# reliably and regardless of how deep the app is installed. (/etc/passwd is
# the classic target too, once enough ../ hops reach the filesystem root.)
with open(os.path.join(HERE, "flag.txt"), "w") as fh:
fh.write("EYALSEC{path_traversal_escaped_the_sandbox}\n")
# The "foreign-code" taint source: a Python module made writable by ANY
# user (mode 0666). Loading code from a file another user can write is the
# supply-chain primitive es-python flags as `foreign-code:<path>`.
foreign = os.path.join(tempfile.gettempdir(), "eyalsec_demo_foreign.py")
with open(foreign, "w") as fh:
fh.write("MESSAGE = 'code loaded from a world-writable file'\n")
try:
os.chmod(foreign, 0o666)
except OSError:
pass
return foreign
def setup_shadow_module():
"""Plant a file named after a stdlib module, earlier on sys.path than the
real one. `colorsys` is chosen because nothing in this app (or in flask)
imports it, so the import in /source/shadow-import is a genuine
sys.modules miss - which is the only condition under which the detector
runs at all.
"""
shadow_dir = os.path.join(tempfile.gettempdir(), "eyalsec_demo_shadow")
os.makedirs(shadow_dir, exist_ok=True)
with open(os.path.join(shadow_dir, "colorsys.py"), "w") as fh:
fh.write(
"# Not the standard library's colorsys. This is the shadowing file.\n"
"EYALSEC_SHADOW = True\n"
"def rgb_to_hls(r, g, b):\n"
" return ('attacker-controlled implementation', r, g, b)\n"
)
return shadow_dir
FOREIGN_MODULE = setup_fixtures()
SHADOW_DIR = setup_shadow_module()
def db_connect():
return sqlite3.connect(DB_PATH)
# ===========================================================================
# SOCKET SOURCE
# Flask reads every HTTP request off a socket. Under es-python with the socket
# source armed, request query/body data is tainted the moment it is recv()'d,
# so each route below just steers that tainted data into a different sink.
# ===========================================================================
@app.get("/sqli")
def sqli():
"""SOURCE socket -> SINK sqlite3.execute => SQL injection (OWASP A03).
Try: /sqli?name=alice
/sqli?name=x' OR '1'='1 (dumps every row, secrets included)
EyalSec reports: tainted SQL statement string at the sqlite3 sink.
"""
name = request.args.get("name", "alice")
query = "SELECT id, name, bio, secret FROM users WHERE name = '" + name + "'"
db = db_connect()
try:
rows = db.execute(query).fetchall() # <-- SINK
finally:
db.close()
return {"query": query, "rows": rows}
@app.get("/cmd")
def cmd():
"""SOURCE socket -> SINK os.system => OS command injection.
Try: /cmd?host=127.0.0.1
/cmd?host=127.0.0.1; id (runs `id` after the ping)
EyalSec reports: tainted command string at the os.system sink.
"""
host = request.args.get("host", "127.0.0.1")
cmd_str = "ping -c 1 " + host
code = os.system(cmd_str) # <-- SINK
return {"ran": cmd_str, "exit_code": code}
@app.get("/cmd-subprocess")
def cmd_subprocess():
"""SOURCE socket -> SINK subprocess(shell=True) => command injection.
Try: /cmd-subprocess?host=127.0.0.1; whoami
EyalSec reports: tainted argument at the subprocess sink.
"""
host = request.args.get("host", "127.0.0.1")
cmd_str = "ping -c 1 " + host
out = subprocess.run(cmd_str, shell=True, capture_output=True, text=True) # <-- SINK
return {"ran": cmd_str, "stdout": out.stdout, "stderr": out.stderr}
@app.get("/eval")
def eval_():
"""SOURCE socket -> SINK eval => arbitrary code / expression injection.
Try: /eval?expr=6*7
/eval?expr=__import__('os').getuid()
EyalSec reports: tainted source passed to eval.
"""
expr = request.args.get("expr", "6*7")
return {"expr": expr, "result": repr(eval(expr))} # <-- SINK
@app.get("/exec")
def exec_():
"""SOURCE socket -> SINK exec => arbitrary statement injection.
Try: /exec?code=open('/tmp/pwned','w').write('x')
EyalSec reports: tainted source passed to exec.
"""
code = request.args.get("code", "x = 1")
ns = {}
exec(code, ns) # <-- SINK
return {"code": code, "namespace_keys": [k for k in ns if not k.startswith("__")]}
@app.post("/pickle")
def unpickle():
"""SOURCE socket -> SINK pickle.loads => insecure deserialization (RCE).
Try: POST raw pickle bytes as the request body.
EyalSec reports: tainted bytes reaching pickle.loads (deserialization gadget path).
"""
obj = pickle.loads(request.get_data()) # <-- SINK
return {"unpickled": repr(obj)}
@app.post("/marshal")
def unmarshal():
"""SOURCE socket -> SINK marshal.loads => deserialization of untrusted data.
Try: POST raw marshal bytes as the request body.
EyalSec reports: tainted bytes reaching marshal.loads.
"""
obj = marshal.loads(request.get_data()) # <-- SINK
return {"unmarshalled": repr(obj)}
@app.get("/path")
def path_traversal():
"""SOURCE socket -> SINK open() => path traversal / arbitrary file read.
Try: /path?file=notes.txt
/path?file=../flag.txt (escapes the sample/ sandbox)
/path?file=../../../../etc/passwd (classic, on a shallow install)
EyalSec reports: tainted path at the file-open sink.
"""
fname = request.args.get("file", "notes.txt")
full = SAMPLE_DIR + "/" + fname # naive join, escapable with ../
with open(full) as fh: # <-- SINK
return Response(fh.read(), mimetype="text/plain")
@app.get("/ssrf")
def ssrf():
"""SOURCE socket -> SINK urllib/socket connect => server-side request forgery.
Try: /ssrf?url=http://example.com
/ssrf?url=http://169.254.169.254/latest/meta-data/ (cloud metadata)
EyalSec reports: tainted host/URL at the outbound-connect sink.
"""
url = request.args.get("url", "http://example.com")
try:
with urllib.request.urlopen(url, timeout=4) as resp: # <-- SINK
body = resp.read(2048)
except Exception as exc:
# The interesting SSRF targets do NOT return a friendly 200: IMDSv2
# answers a tokenless GET with 401, and a closed internal port refuses
# the connection outright. Both are still successful forgeries - the
# request left this server for an address the attacker chose, which is
# exactly what the sink reports. So report the outcome instead of
# rendering a 500 that makes a working demo look broken.
return {"url": url, "reached": False,
"error": f"{exc.__class__.__name__}: {exc}",
"note": "the outbound connect still happened - the sink fired "
"on the tainted target before any reply came back"}
return Response(body, mimetype="text/plain")
@app.post("/xxe")
def xxe():
"""SOURCE socket -> SINK XML parser => XML external entity / injection.
Try: POST an XML document as the request body.
EyalSec reports: tainted document reaching the expat/ElementTree parse sink.
"""
root = ET.fromstring(request.get_data()) # <-- SINK
return {"root_tag": root.tag, "text": (root.text or "").strip()}
@app.get("/redos")
def redos():
"""SOURCE socket -> SINK re.search => regular-expression DoS / injection.
Try: /redos?pattern=(a%2B)%2B%24&text=aaaaaaaaaaaaaaaaaaaaaaX
Percent-encode the "+" and "$". A bare "+" in a query string decodes to a
space, so `(a+)+$` arrives as `(a ) $` - a pattern that matches nothing
and backtracks not at all. Add more a's to widen the blowup; each one
doubles the work: 18 a's takes 0.08s, 20 takes 0.29s, 22 takes 1.1s and
24 takes 6.2s on the box this was measured on.
EyalSec reports: tainted pattern at the regex-compile sink, plus `redos`
once the match runs past its work threshold. Known blind spot: a blowup
that lives entirely inside a single-character repeat (such as `a*`) can
stay under the threshold.
"""
pattern = request.args.get("pattern", "hello")
text = request.args.get("text", "hello world")
match = re.search(pattern, text) # <-- SINK
return {"pattern": pattern, "matched": bool(match)}
@app.get("/csv")
def csv_injection():
"""SOURCE socket -> SINK csv.writer => CSV / formula injection.
Try: /csv?row=name,=cmd|'/C calc'!A0
EyalSec reports: tainted field at the csv sink (dangerous leading =,+,-,@).
"""
row = request.args.get("row", "a,b,c")
sio = io.StringIO()
csv.writer(sio).writerow(row.split(",")) # <-- SINK
return Response(sio.getvalue(), mimetype="text/csv")
@app.get("/format")
def format_string():
"""SOURCE socket -> SINK str.format => format-string injection / info leak.
Try: /format?template=hi {name}
/format?template={secrets[api_key]} (leaks a server secret)
EyalSec reports: tainted format template at the str.format sink.
"""
template = request.args.get("template", "hi {name}")
rendered = template.format(name="guest", secrets=APP_SECRETS) # <-- SINK
return {"template": template, "rendered": rendered}
@app.get("/import")
def dynamic_import():
"""SOURCE socket -> SINK importlib.import_module => dynamic import injection.
Try: /import?module=math
/import?module=os (attacker chooses which module loads)
EyalSec reports: tainted module name at the import sink.
"""
import importlib
module = request.args.get("module", "math")
mod = importlib.import_module(module) # <-- SINK
return {"module": module, "attributes": [a for a in dir(mod) if not a.startswith("_")][:12]}
@app.get("/mkfifo")
def mkfifo():
"""SOURCE socket -> SINK os.mkfifo => path injection into a filesystem call.
Try: /mkfifo?name=demo.fifo
/mkfifo?name=../../tmp/escape.fifo
EyalSec reports: tainted path at the mkfifo posix sink.
"""
name = request.args.get("name", "demo.fifo")
path = os.path.join(tempfile.gettempdir(), name)
try:
os.mkfifo(path) # <-- SINK
made = True
os.unlink(path)
except OSError as exc:
made, path = False, f"{path} ({exc})"
return {"path": path, "created": made}
@app.get("/ctypes")
def ctypes_load():
"""SOURCE socket -> SINK ctypes.CDLL => native library / FFI load injection.
Try: /ctypes?lib=libc.so.6
EyalSec reports: tainted library path at the ctypes/FFI sink.
"""
lib = request.args.get("lib", "libc.so.6")
handle = ctypes.CDLL(lib) # <-- SINK
return {"lib": lib, "loaded": bool(handle._name)}
# ===========================================================================
# NON-SOCKET SOURCES
# These prove the taint engine covers every entry point, not just the network.
# ===========================================================================
@app.get("/source/file")
def source_file():
"""SOURCE file-read -> SINK sqlite3.execute => file-borne SQL injection.
The query text comes from sample/tainted_input.txt (armed with
ES2_MAKE_FILE_VULN), NOT from the request - an attacker who can write that
file owns the query. Edit the file and re-hit this route to see it move.
EyalSec reports: file source -> SQL sink.
"""
with open(os.path.join(SAMPLE_DIR, "tainted_input.txt")) as fh:
value = fh.read().strip() # <-- tainted at the file source
query = "SELECT id, name FROM users WHERE name = '" + value + "'"
db = db_connect()
try:
rows = db.execute(query).fetchall() # <-- SINK
finally:
db.close()
return {"file_value": value, "query": query, "rows": rows}
@app.get("/source/env")
def source_env():
"""SOURCE env var -> SINK os.system => environment-borne command injection.
DEMO_INPUT is read from the process environment (armed with
ES2_MAKE_ENV_VULN) and flows straight into a shell. run-eyalsec.sh sets
DEMO_INPUT='127.0.0.1; id' so you can watch it fire.
EyalSec reports: env source -> shell sink.
"""
value = os.environ.get("DEMO_INPUT", "127.0.0.1") # <-- tainted at the env source
cmd_str = "ping -c 1 " + value
code = os.system(cmd_str) # <-- SINK
return {"env_DEMO_INPUT": value, "ran": cmd_str, "exit_code": code}
@app.get("/source/foreign")
def source_foreign():
"""SOURCE foreign-code -> loading a world-writable module.
Loads a module from a file that ANY user can write (mode 0666). With
ES2_MAKE_FOREIGN_VULN armed, es-python emits `foreign-code:<path>` at load
time - the supply-chain / other-user-tampering primitive.
EyalSec reports: foreign-code:<path>.
"""
import importlib.util
spec = importlib.util.spec_from_file_location("eyalsec_demo_foreign", FOREIGN_MODULE)
mod = importlib.util.module_from_spec(spec)
spec.loader.exec_module(mod) # <-- foreign-code load
return {"loaded_from": FOREIGN_MODULE, "message": mod.MESSAGE}
# ===========================================================================
# COMPOSITE FINDINGS
# Every route below ends at the SAME sink - a real socket send, or a log
# record. What changes is where the data came from, and the taint SOURCE is
# what decides the label es-python reports:
#
# hardcoded (12) -> `credential exfiltration` / `credential logging`
# weak-random (11) -> `predictable token`
# env (5) -> `secret disclosure`
#
# That is the entire reason those sources exist. A secret sitting in a
# variable is not an incident; a secret leaving the process is. No scanner
# can make that distinction, because it depends on data that only exists at
# runtime, at a call site that looks identical in all three cases.
# ===========================================================================
log = logging.getLogger("vulnerable-python")
def _send_over_socket(payload):
"""Send `payload` over a real socket, to a throwaway loopback listener.
The listener exists only so there is a genuine `sendall()` on a genuine
socket for the sink to fire on. The connect target is a hardcoded literal,
so it is untainted and the `ssrf` discriminator correctly stays quiet.
"""
if isinstance(payload, str):
payload = payload.encode()
listener = socket.socket()
listener.bind(("127.0.0.1", 0))
listener.listen(1)
try:
client = socket.create_connection(listener.getsockname())
try:
client.sendall(payload) # <-- SINK
finally:
client.close()
conn, _ = listener.accept()
conn.close()
finally:
listener.close()
return len(payload)
@app.get("/weak-random")
def weak_random_token():
"""SOURCE weak-random -> SINK socket.sendall => predictable token.
A password-reset token built from the non-cryptographic PRNG, then handed
to whoever asked for it. Guessable by anyone who can observe or reproduce
the generator state - and it looks exactly like a good token.
Try: /weak-random
EyalSec reports: `predictable token` at the socket send, origin
`weak-random:getrandbits`. Arm with --make-weakrandom-vuln (flag/env only;
there is no dashboard category for it). The getrandbits source covers
randrange / randint / choice / shuffle / sample / randbytes too - they all
route through it. `uuid4` is deliberately NOT covered: it uses os.urandom
and is a correct choice.
"""
token = "%032x" % random.getrandbits(128) # <-- weak-random source
sent = _send_over_socket("reset-token=" + token)
return {"token": token, "bytes_sent": sent,
"note": "generated with random.getrandbits, not secrets.token_hex"}
@app.get("/credential-exfil")
def credential_exfil():
"""SOURCE hardcoded -> SINK socket.sendall => credential exfiltration.
A hardcoded API key from demo_credentials.py leaves the process over a
socket. This is the shape of a leaked key in an outbound request, a crash
report, or a telemetry payload.
Try: /credential-exfil
EyalSec reports: `credential exfiltration` at the socket send, origin
`hardcoded:demo_credentials.py:<line>`. Arm with --make-secret-vuln.
NOTE: the scan runs when a module is compiled on import, so it runs on the
first, UNCACHED import of demo_credentials.py. `rm -rf __pycache__` and restart
to see it fire again - a __pycache__ hit performs no compile at all.
"""
key = demo_credentials.STRIPE_API_KEY
sent = _send_over_socket("Authorization: Bearer " + key)
return {"sent_header": "Authorization: Bearer " + key, "bytes_sent": sent,
"source_file": "demo_credentials.py"}
@app.get("/credential-logging")
def credential_logging():
"""SOURCE hardcoded -> SINK logging => credential logging.
The same secret, same taint number, different sink: written to the
application log, where it is retained, shipped to a log aggregator, and
read by everyone with log access.
Try: /credential-logging
EyalSec reports: `credential logging`. The logging sink inspects
record.msg and each of record.args, NOT record.getMessage(), so it sees
the secret even though this call never formats the string itself.
"""
token = demo_credentials.GITHUB_TOKEN
log.warning("provisioning repository with token %s", token) # <-- SINK
return {"logged_token": token, "sink": "logging.Logger.handle"}
@app.get("/secret-disclosure")
def secret_disclosure():
"""SOURCE env -> SINK socket.sendall => secret disclosure.
Environment variables are where deployments keep their secrets, so an env
value crossing a socket gets its own label rather than a generic send.
Try: /secret-disclosure (uses DEMO_SECRET, else HOME)
EyalSec reports: `secret disclosure` at the socket send. Arm with
ES2_MAKE_ENV_VULN. Worth knowing: with the env source armed, es-python
taints EVERY os.environ value, so on a real app this is a source that
wants a rule scoping it.
"""
value = os.environ.get("DEMO_SECRET") or os.environ.get("HOME", "/home/demo")
sent = _send_over_socket("telemetry-env=" + value)
return {"value": value, "bytes_sent": sent}
@app.get("/log-forging")
def log_forging():
"""SOURCE socket -> SINK logging => log forging.
A newline in attacker-controlled data lets them write a whole extra log
line - forging an audit trail, or hiding in one.
Try: /log-forging?user=alice
/log-forging?user=alice%0A2026-01-01 00:00:00 ADMIN login succeeded
EyalSec reports: `log forging` when a tainted message or arg carries CR
or LF.
"""
user = request.args.get("user", "alice")
log.info("login for %s", user) # <-- SINK
return {"user": user, "sink": "logging.Logger.handle"}
# ===========================================================================
# SOURCES ADDED AFTER THE FIRST RELEASE
# Neither of these is a data source in the ordinary sense. `ssl-unverified`
# reclassifies bytes the socket source already tainted; `shadow-import` is a
# code-provenance finding that taints nothing at all and posts its own event.
# ===========================================================================
@app.get("/source/tls-unverified")
def source_tls_unverified():
"""SOURCE ssl-unverified -> SINK sqlite3.execute => trusted-looking bytes.
TLS with certificate verification switched off. The connection is still
encrypted, so every "is it HTTPS?" check passes, but the peer was never
authenticated - anyone in the path can be the peer. es-python
reclassifies the decrypted plaintext so the data carries that fact all
the way to the sink.
Try: /source/tls-unverified (defaults to example.com:443)
/source/tls-unverified?host=...
EyalSec reports: origin `ssl-unverified:<host>`, or
`ssl-unverified:<host> (hostname-unchecked)` when the chain is verified
but check_hostname is off. This one folds under the SOCKET gate
(ES2_MAKE_SOCKET_VULN). Needs outbound network; returns a plain error if there is none.
"""
host = request.args.get("host", "example.com")
ctx = ssl.create_default_context()
ctx.check_hostname = False
ctx.verify_mode = ssl.CERT_NONE # <-- the peer is now unauthenticated
try:
with socket.create_connection((host, 443), timeout=5) as raw:
with ctx.wrap_socket(raw, server_hostname=host) as tls:
tls.sendall(b"HEAD / HTTP/1.0\r\nHost: " + host.encode() + b"\r\n\r\n")
banner = tls.recv(256).decode("latin-1") # <-- ssl-unverified source
except OSError as exc:
return {"host": host, "error": f"no outbound TLS from this box ({exc})",
"note": "the source still exists; this endpoint just needs egress"}
first = banner.splitlines()[0] if banner.splitlines() else ""
query = "SELECT id, name FROM users WHERE bio = '" + first + "'"
db = db_connect()
try:
rows = db.execute(query).fetchall() # <-- SINK
finally:
db.close()
return {"host": host, "verified": False, "banner": first,
"query": query, "rows": rows}
@app.get("/source/shadow-import")
def source_shadow_import():
"""SOURCE shadow-import -> an event at import time, no taint involved.
A file named after a standard-library module, sitting earlier on sys.path
than the real one. `import colorsys` then loads the attacker's file. This
is the dependency-confusion / path-shadowing primitive, and it is invisible
to the foreign-code detector because a file you wrote in your own cwd is
not writable by *another* user - so it gets its own emitter.
Try: /source/shadow-import
EyalSec reports: `shadow import`, origin `shadow-import:<path>`, taint
number 13. Folds under the FOREIGN gate (ES2_MAKE_FOREIGN_VULN). It fires
only on a sys.modules MISS - once per module name per process, capped at
64 remembered names - so the event appears once even though this route
re-imports on every hit.
"""
sys.modules.pop("colorsys", None)
if SHADOW_DIR not in sys.path:
sys.path.insert(0, SHADOW_DIR)
importlib.invalidate_caches()
mod = importlib.import_module("colorsys") # <-- shadow-import
return {"module": "colorsys",
"loaded_from": getattr(mod, "__file__", "?"),
"real_stdlib_path": os.path.join(os.path.dirname(os.__file__), "colorsys.py"),
"is_shadow": getattr(mod, "EYALSEC_SHADOW", False)}
# ===========================================================================
# THE FOREIGN GATE, IN FULL (ES2_MAKE_FOREIGN_VULN / --make-foreign-vuln)
#
# Every other source answers "is this data attacker-controlled?". This one
# answers "could a user other than me have written this?" - a question about
# PROVENANCE, which is why it fires on code that no attacker has touched yet
# and on data that no request has carried.
#
# A file is foreign when any of three rules matches it:
#
# owned by another (non-root) user
# world-writable
# group-writable, and the group has another real member
#
# Reads additionally accept a fourth: the immediate parent directory lets
# another user rename or replace the file. The sticky bit is honoured, so an
# ordinary file you own in /tmp is NOT foreign - see /foreign/controls.
#
# Root-owned files are trusted on purpose. Root can replace anything on the
# box, so treating root-owned code as untrusted would report the whole stdlib
# on every import and the signal would be worth nothing.
#
# The gate then drives three distinct behaviours, one per group of routes
# below: code-load events, data taint, and provenance recovery for exec/eval/
# compile of a bare string. `shadow-import` (/source/shadow-import, above)
# folds under this same gate.
#
# ONE EVENT PER PATH PER PROCESS. Each route uses its own fixture file for
# that reason; a repeat hit on the same route is deliberately silent until you
# restart the app.
# ===========================================================================
FOREIGN = foreign_lab.build()
# es-python builtins, with inert stand-ins so this file still runs under
# plain python3. Same trick as demos/es_compat.py, kept local so app.py imports
# nothing from demos/.
_make_vuln = getattr(builtins, "make_vuln", lambda obj, taint=None: obj)
_get_taint = getattr(builtins, "get_taint", lambda obj: None)
def _import_by_name(module_name, directory):
"""Import `module_name` from `directory` through a genuine sys.path entry.
Forced to be a real import each time (sys.modules purge + invalidated
caches) so the loader actually opens the file - a cached module performs no
load and therefore produces no event.
"""
sys.modules.pop(module_name, None)
if directory not in sys.path:
sys.path.insert(0, directory)
importlib.invalidate_caches()
return importlib.import_module(module_name)
def _foreign_report(path, **extra):
"""Standard body: the path, the rule-by-rule verdict, and the route's data."""
body = {"loaded_from": path, "verdict": foreign_lab.verdict(path)}
body.update(extra)
return body
# --------------------------------------------------------------------------- #
# 1. CODE LOADS. No taint, no sink, no data flow - the load is the finding.
# --------------------------------------------------------------------------- #
@app.get("/foreign/world-writable")
def foreign_world_writable():
"""SOURCE foreign-code -> `import` of a world-writable module.
Mode 0666 on a file that is on sys.path. Any local user - a compromised
service account, a container sharing the volume, a CI runner - can rewrite
it between two runs of this app, and the next import executes whatever they
left behind. The file's CONTENT is innocent right now, which is exactly why
a scanner reading it finds nothing.
Try: /foreign/world-writable
EyalSec reports: `foreign-code:<path>`, origin taint number 3, at the
import. Under ES2_RAISE the import is aborted with a RuntimeError.
"""
mod = _import_by_name("eyalsec_ww_module", FOREIGN["import_dir"])
return _foreign_report(FOREIGN["ww_module"],
rule="world-writable (o+w)",
message=mod.MESSAGE)
@app.get("/foreign/other-user")
def foreign_other_user():
"""SOURCE foreign-code -> `import` of a module OWNED BY ANOTHER USER.
The headline case, and the one that needs ./setup-foreign.sh (an
unprivileged process cannot create a file it does not own). Note the mode
is 0644: I cannot write this file at all. It is untrusted because the user
who CAN write it is not me - which is what "supply chain" means once the
package is already on disk.
Try: ./setup-foreign.sh then /foreign/other-user
EyalSec reports: `foreign-code:/var/tmp/eyalsec-foreign/other_user_module.py`,
with the owning uid and mode in the event body.
"""
path = foreign_lab.sudo_fixture("other_user_module.py")
if path is None:
return foreign_lab.not_set_up("other_user_module.py",
"a Python module owned by a different user")
mod = _import_by_name("other_user_module", foreign_lab.SUDO_DIR)
return _foreign_report(path,
rule="owned by another user (uid mismatch, not root)",
message=mod.MESSAGE)
@app.get("/foreign/group-writable")
def foreign_group_writable():
"""SOURCE foreign-code -> `import` of a GROUP-WRITABLE module.
Mine, mode 0664, in a group somebody else belongs to. The subtlety is that
the mode bits alone decide nothing: es-python resolves the group's real
membership - including users whose PRIMARY group it is, who never appear in
the group entry - and stays silent when the group is a private one-member
group, which is what most distributions hand out. So this rule fires on
genuinely shared write access and not on the default layout of a home
directory.
Try: ./setup-foreign.sh then /foreign/group-writable
EyalSec reports: `foreign-code:<path>` - or nothing at all if the group
turns out to have exactly one member, which is a correct answer.
"""
path = FOREIGN.get("group_module") or foreign_lab.sudo_fixture("group_writable_module.py")
if path is None:
gid, gname, others = foreign_lab.usable_shared_group()
return {"status": "no usable group on this box",
"detail": "every group this process belongs to has exactly one "
"real member, so a group-writable file here would be "
"private and es-python would correctly say nothing",
"how": foreign_lab.SETUP_HINT}
directory, name = os.path.split(path)
mod = _import_by_name(name[:-3], directory)
return _foreign_report(path,
rule="group-writable and the group has another member",
message=mod.MESSAGE)
@app.get("/foreign/pyc")
def foreign_pyc():
"""SOURCE foreign-code -> loading a world-writable `.pyc`, with no source.
The same attack shipped as bytecode. There is no `.py` beside it, so there
is nothing for a source scanner, a linter or a code review to read; the
file is a marshalled code object that runs on import like any other module.
Provenance is all that is left to judge it by, and provenance is what this
detector uses.
Try: /foreign/pyc
EyalSec reports: `foreign-code:<path>.pyc` at the bytecode load. Loads
with no source on disk are covered as well as ordinary ones.
"""
import py_compile
py_compile.compile(FOREIGN["ww_pyc_source"], cfile=FOREIGN["ww_pyc"],
doraise=True)
os.chmod(FOREIGN["ww_pyc"], 0o666)
name = "eyalsec_ww_bytecode"
sys.modules.pop(name, None)
loader = importlib.machinery.SourcelessFileLoader(name, FOREIGN["ww_pyc"])
spec = importlib.util.spec_from_file_location(name, FOREIGN["ww_pyc"],
loader=loader)
mod = importlib.util.module_from_spec(spec)
spec.loader.exec_module(mod) # <-- foreign-code load, bytecode only
return _foreign_report(FOREIGN["ww_pyc"],
rule="world-writable .pyc, no source on disk",
message=mod.MESSAGE)
@app.get("/foreign/compile")
def foreign_compile():
"""SOURCE foreign-code -> `compile(src, path, "exec")` with a foreign path.
An app that implements its own plugin loading does not call `import`; it
reads a file and compiles it. es-python checks the FILENAME argument of
the builtin `compile()` for exactly that, so a hand-rolled loader is
covered the same way importlib is.
Try: /foreign/compile
EyalSec reports: `compile`, origin `foreign-code:<path>`. The source string
is itself tainted (it was read from a foreign file), so it would separately
qualify as an untrusted code load - but the path-based event has already
claimed this load, so there is no duplicate `compile (untrusted code)`
event. You will still see the ordinary tainted-value event for the string
reaching compile, which is a different finding about a different fact.
"""
path = FOREIGN["ww_compile"]
with open(path) as fh:
src = fh.read()
code = compile(src, path, "exec") # <-- SINK: foreign path at compile()
ns = {}
exec(code, ns)
return _foreign_report(path,
rule="world-writable file named as compile()'s filename",
compiled_names=[k for k in ns if not k.startswith("__")],
value=ns.get("COMPILED"))
@app.get("/foreign/exec-file")
def foreign_exec_file():
"""SOURCE foreign-code -> `exec(open(path).read())`, filename `<string>`.
The load with no filename at all. The bytes were read as DATA, so by the
time they reach exec the interpreter is looking at an anonymous string and
every path-based control - audit hooks, allowlists, EDR file rules, a SAST
pass over the repo - has nothing to key on. es-python follows the string's
taint chain back to the read that produced it and reports the original
path, so this lands as the same `foreign-code:<path>` event that a direct
import would have produced.
Try: /foreign/exec-file
/foreign/exec-file?sink=eval
/foreign/exec-file?sink=compile
EyalSec reports: `exec (foreign code)` / `eval (foreign code)` /
`compile (foreign code)`. This raise is SYNCHRONOUS under ES2_RAISE - the
source is never compiled and never runs, unlike the sink-side raises
elsewhere in this app, which fire at the sink after the fact.
See demos/uncatchable_plugin.py for the same idea as a CLI demo.
"""
sink = request.args.get("sink", "exec")
path = FOREIGN["ww_source"]
with open(path) as fh:
src = fh.read() # <-- tainted: the file is foreign
ns = {}
if sink == "eval":
result = eval(src.split("=", 1)[1].strip()) # <-- SINK
elif sink == "compile":
result = repr(compile(src, "<string>", "exec")) # <-- SINK
else:
exec(src, ns) # <-- SINK
result = ns.get("RESULT")
return _foreign_report(path, sink=sink, filename_seen_by_python="<string>",
result=result,
note="the path in the event was recovered from the "
"taint chain, not from the call")
# --------------------------------------------------------------------------- #
# 2. DATA TAINT. The file gate taints EVERY file read on the box; the foreign
# gate taints only the reads another user could have controlled - which is
# what makes it something you can leave armed in production.
# --------------------------------------------------------------------------- #
@app.get("/foreign/data/read")
def foreign_data_read():
"""SOURCE foreign file read -> SINK sqlite3.execute => SQL injection.
Same shape as /source/file, one gate down: this fires with
ES2_MAKE_FILE_VULN switched OFF, because the file is world-writable and so
its contents are attacker-controlled by definition. Arm only
ES2_MAKE_FOREIGN_VULN and compare the two routes.
Try: /foreign/data/read
EyalSec reports: a tainted SQL statement at the sqlite3 sink, origin the
file's path.
"""
path = FOREIGN["ww_sql"]
with open(path) as fh:
value = fh.read().strip() # <-- tainted by the foreign gate alone
query = "SELECT id, name FROM users WHERE name = '" + value + "'"
db = db_connect()
try:
rows = db.execute(query).fetchall() # <-- SINK
finally:
db.close()
return _foreign_report(path, value=value, query=query, rows=rows)
@app.get("/foreign/data/readlines")
def foreign_data_readlines():
"""SOURCE foreign file read -> SINK os.system => command injection.
`readlines()` rather than `read()`. It is a separate read path with its
own taint marking and its own origin reporting, so it gets its own route: a
per-line read used to reach the sink with the origin missing, and the
dashboard would attribute the finding to the line of code instead of to the
file.
Try: /foreign/data/readlines
EyalSec reports: a tainted command string at the shell sink, origin the
hosts file.
"""
path = FOREIGN["ww_lines"]
with open(path) as fh:
lines = fh.readlines() # <-- each line tainted
target = lines[-1].strip() if lines else "127.0.0.1"
cmd = "ping -c 1 " + target
code = os.system(cmd) # <-- SINK
return _foreign_report(path, lines=[l.rstrip("\n") for l in lines],
ran=cmd, exit_code=code)
@app.get("/foreign/data/os-read")
def foreign_data_os_read():
"""SOURCE foreign fd -> SINK subprocess(shell=True) => command injection.
Raw `os.open` + `os.read`, with no file object anywhere. The verdict is
made on the file DESCRIPTOR (`fstat`), which is why this still works when
the code never touches a path after the open.
Try: /foreign/data/os-read
EyalSec reports: a tainted argument at the subprocess sink.
"""
path = FOREIGN["ww_cmd"]
fd = os.open(path, os.O_RDONLY)
try:
raw = os.read(fd, 4096) # <-- tainted: the fd is foreign
finally:
os.close(fd)
target = raw.decode().strip()
out = subprocess.run("ping -c 1 " + target, shell=True, # <-- SINK
capture_output=True, text=True)
return _foreign_report(path, target=target, exit_code=out.returncode,
stdout=out.stdout[-400:])
@app.get("/foreign/data/mmap")
def foreign_data_mmap():
"""SOURCE foreign mmap -> SINK sqlite3.execute => SQL injection.
A memory-mapped foreign file. There is no `read()` call at all - the bytes
arrive by page fault - so a tool watching read calls would miss this
entirely. es-python judges the file when it is mapped and taints every
slice taken from it.
Try: /foreign/data/mmap