JS 逆向参数对不上?用 Python 和 Node.js 对齐 URL 编码、JSON 与 Unicode 字节

复现自己系统或授权接口的前端请求时,参数看起来一样,校验结果却不同,应该先把计算前的输入保存成字节,找到第一个差异位置。对象相等、界面显示相同,都不能证明送入计算函数的字节相同。

这篇用完全虚构的本地协议,复现 8 类差异,再让 Node.js 和 Python 生成完全一致的输入。SHA-256 只用于核对实验结果,没有密钥,不能用于证明请求身份,也不是接口鉴权方案。

验证日期为 2026-09-08,环境为 Python 3.9.6、Node.js v24.19.0,无第三方依赖、不发送网络请求。

1. 先分清“解析结果”和“原始输入”

q=AI+agentq=AI%20agent 按表单查询参数规则解析后,都能得到带空格的 AI agent。但是原始字符串一个长 10 字节,另一个长 12 字节。

URLSearchParams.toString() 会把空格编码成 +encodeURIComponent() 编码空格得到 %20。采用哪个方法,取决于协议实际要求,不能只根据参数值相同就互换。字面量加号还需要与空格区分,本例统一编码为 %2BMDN:URLSearchParams 序列化MDN:encodeURIComponent

重复键也是容易丢掉的信息。tag=python&tag=js 转成普通字典后,只剩一个 tag。顺序调换虽然仍包含两个值,原始字节也会改变。Python 可以用有序二元组列表传给 urlencode(),保留这种输入结构。Python:urllib.parse

2. JSON 和 Unicode 也会改变字节

Python 的 json.dumps() 默认会转义非 ASCII 字符,并在部分分隔符后插入空格。使用 ensure_ascii=False, separators=(',', ':'),可以在本文限定的数据里与紧凑 JSON 对齐;这两个参数不是任意对象的跨语言规范化方案。Python:JSON 编码参数

两个 JSON 文本解析为相同的对象,仍可能有不同的键顺序、空白和转义。JavaScript 的 JSON.stringify() 也有自己的属性访问顺序,以及对 undefined、非有限数值等的处理规则。排查时保留序列化后的文本,不要先重新解析、美化,再拿它当原始输入。MDN:JSON.stringify

Café 中的最后一个字符,可以是单个 é,也可以是 e 后接组合重音。两种写法看起来接近,UTF-8 字节却不同。NFC 可以把这个例子归一,但只有协议明确采用它时才能这样处理;单方面加上归一化也可能制造不一致。MDN:Unicode normalize

3. 一套可复现的虚构协议

本例约定查询参数和正文均由“有序字符串对”组成;重复项保留,字符串采用 NFC;查询参数空格采用 %20,非保留字符集合为字母、数字和 -._~;正文采用无额外空白的 JSON 数组。

计算输入为:版本字符串、换行、查询字符串、换行、正文,最后不追加换行,整体使用 UTF-8。这是本地实验约定,不应套用到其他接口。

先保存 parameter_bytes_demo.mjs

import { createHash } from 'node:crypto';

// Fictional protocol: ordered string pairs, NFC, strict percent encoding.
const queryPairs = [
  ['q', 'AI + 爬虫'], ['tag', 'python'], ['tag', 'js'],
  ['word', 'Cafe\u0301'], ['punct', "!*'()~"],
];
const bodyPairs = [['name', 'Cafe\u0301'], ['page', '1']];
const normalize = pairs => pairs.map(pair => pair.map(s => s.normalize('NFC')));
const encode = s => encodeURIComponent(s).replace(
  /[!'()*]/g, c => '%' + c.charCodeAt(0).toString(16).toUpperCase()
);
const query = normalize(queryPairs).map(
  ([key, value]) => `${encode(key)}=${encode(value)}`
).join('&');
const body = JSON.stringify(normalize(bodyPairs));
const payload = `demo-v1\n${query}\n${body}`;
const bytes = Buffer.from(payload, 'utf8');
console.log(JSON.stringify({
  formSpace: new URLSearchParams([['q', 'AI agent']]).toString(),
  componentSpace: 'q=' + encodeURIComponent('AI agent'),
  query,
  body,
  hex: bytes.toString('hex'),
  // Diagnostic digest only. This is NOT an authentication scheme.
  sha256: createHash('sha256').update(bytes).digest('hex'),
}));

再保存 parameter_bytes_demo.py

import hashlib
import json
import sys
import unicodedata
from pathlib import Path
from urllib.parse import parse_qsl, quote, urlencode


def check(condition, label):
    if not condition:
        raise AssertionError(label)


def compare(label, left, right):
    a, b = left.encode('utf-8'), right.encode('utf-8')
    check(a != b, label)
    first = next((i for i, (x, y) in enumerate(zip(a, b)) if x != y),
                 min(len(a), len(b)))
    print(f'{label}: bytes={len(a)}/{len(b)}, first_diff={first}')


space_a, space_b = 'q=AI+agent', 'q=AI%20agent'
check(parse_qsl(space_a) == parse_qsl(space_b), 'form decoding')
compare('space', space_a, space_b)
pairs = [('tag', 'python'), ('tag', 'js')]
compare('duplicate', urlencode(pairs), urlencode(dict(pairs)))
compare('order', urlencode(pairs), urlencode(list(reversed(pairs))))
obj = {'name': '爬虫', 'page': '1'}
compact = json.dumps(obj, ensure_ascii=False, separators=(',', ':'))
compare('json_escape', json.dumps(obj, separators=(',', ':')), compact)
compare('json_space', json.dumps(obj, ensure_ascii=False), compact)
compare('json_order', compact, json.dumps(
    dict(reversed(list(obj.items()))), ensure_ascii=False, separators=(',', ':')))
compare('unicode', 'Caf\u00e9', 'Cafe\u0301')
compare('newline', compact, compact + '\n')

query_pairs = [
    ('q', 'AI + 爬虫'), ('tag', 'python'), ('tag', 'js'),
    ('word', 'Cafe\u0301'), ('punct', "!*'()~"),
]
body_pairs = [('name', 'Cafe\u0301'), ('page', '1')]


def normalize(pairs):
    return [[unicodedata.normalize('NFC', s) for s in pair] for pair in pairs]


query = '&'.join(quote(k, safe='-._~') + '=' + quote(v, safe='-._~')
                 for k, v in normalize(query_pairs))
body = json.dumps(normalize(body_pairs), ensure_ascii=False, separators=(',', ':'))
payload = f'demo-v1\n{query}\n{body}'.encode('utf-8')
digest = hashlib.sha256(payload).hexdigest()

if len(sys.argv) != 2:
    raise SystemExit('Usage: python3 parameter_bytes_demo.py node_fixture.json')
js = json.loads(Path(sys.argv[1]).read_text(encoding='utf-8'))
check(js['formSpace'] == space_a, 'Node form space')
check(js['componentSpace'] == space_b, 'Node component space')
check(js['query'] == query, 'cross-language query')
check(js['body'] == body, 'cross-language body')
check(js['hex'] == payload.hex(), 'cross-language bytes')
check(js['sha256'] == digest, 'cross-language digest')
print('query=' + query)
print('body=' + body)
print('sha256=' + digest)
print('PASS: 8 byte-difference cases; Node/Python payload identical')

在两个文件所在目录运行:

node parameter_bytes_demo.mjs > parameter_bytes_node_fixture.json
python3 parameter_bytes_demo.py parameter_bytes_node_fixture.json

Node 输出真实序列化结果,Python 先检查 8 类差异,再读取这个结果逐项比对。测试比较了实际字节,摘要是额外核对项。Node 使用的摘要 API 见官方 createHash 文档

4. 本地运行结果

space: bytes=10/12, first_diff=4
duplicate: bytes=17/6, first_diff=4
order: bytes=17/17, first_diff=4
json_escape: bytes=34/28, first_diff=9
json_space: bytes=31/28, first_diff=8
json_order: bytes=28/28, first_diff=2
unicode: bytes=5/6, first_diff=3
newline: bytes=28/29, first_diff=28
query=q=AI%20%2B%20%E7%88%AC%E8%99%AB&tag=python&tag=js&word=Caf%C3%A9&punct=%21%2A%27%28%29~
body=[["name","Café"],["page","1"]]
sha256=b11b31e5418b928b9c4be02798443cbaf2b93c6be51fa53f8764183a27ba4efa
PASS: 8 byte-difference cases; Node/Python payload identical

first_diff 是从 0 开始的字节位置。如果短输入是长输入的前缀,就显示短输入长度。因此最后一项定位的是额外换行,而不是某个字符替换。

5. 用到实际排障时,保留哪些证据

先固定一组脱敏输入,分别保留:原始有序参数、序列化文本、编码名称、字节长度、首个差异附近的十六进制。按“组装参数 → 序列化 → 编码 → 计算”逐层比对,哪一层开始不同,就在那一层检查约定。

自动排序、反复 URL 解码、去掉所有空白、把重复键装进字典,都会改变输入,不能作为默认修复。AI 可以协助整理差异假设和生成边界样例,最后应由固定用例证明两端行为一致。

本实验仅验证列出的字符串、顺序和编码规则。它不覆盖任意对象的规范化、浮点数、孤立代理项、任意 Unicode 的跨版本兼容,也不证明请求一定能通过服务端业务校验。新增数据类型时,应先补协议规则和跨语言用例,再扩展实现。

本文由 AI 辅助整理与编写,示例已在上述本地环境运行验证,未测试任何真实网站接口。

评论
成就一亿技术人!
拼手气红包6.0元
还能输入1000个字符
 
 条评论被折叠 查看
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值