2024年4月

我们引入了嵌入量化的概念,并展示了它们对检索速度、内存使用、磁盘空间和成本的影响。我们将讨论理论上和实践中如何对嵌入进行量化,然后介绍一个
演示
,展示了 4100 万维基百科文本的真实检索场景。

目录

为什么使用嵌入?

嵌入是自然语言处理中最多样化的工具之一,支持各种设置和使用场景。本质上,嵌入是对更复杂对象 (如文本、图像、音频等) 的数值表示。具体来说,这些对象被表示为 n 维向量。

在转换了复杂对象之后,你可以通过计算相应嵌入的相似性来确定它们的相似性!这对于许多使用场景至关重要: 它为推荐系统、检索、单次学习或少样本学习、异常检测、相似性搜索、释义检测、聚类、分类等提供了基础。

嵌入可能难以扩展

但是,当我们在实际应用中使用嵌入时,可能会遇到一些问题。比如,现在很多先进的模型产生的嵌入都是 1024 维的,每个维度需要 4 字节的空间来存储 (float 32 编码)。如果你要处理 2.5 亿个这样的向量,那就需要大约 1TB 的内存,这既花钱又可能导致处理速度变慢。

下表展示了一些不同的模型,它们的维度大小、需要的内存量以及相应的成本。成本是按照 AWS 上一种叫做 x2gd 的实例来估算的,大概每个月每 GB 需要 3.8 美元。

嵌入维数 模型样例 100M 嵌入 250M 嵌入 1B 嵌入
384 all-MiniLM-L6-v2
bge-small-en-v1.5
143.05GB
$543 / mo | 357.62GB
$1,358 / mo
1430.51GB
$5,435 / mo
768 all-mpnet-base-v2
bge-base-en-v1.5
jina-embeddings-v2-base-en
nomic-embed-text-v1
286.10GB
$1,087 / mo|715.26GB
$2,717 / mo
2861.02GB
$10,871 / mo
1024 bge-large-en-v1.5
mxbai-embed-large-v1
Cohere-embed-english-v3.0
381.46GB
$1,449 / mo|953.67GB
$3,623 / mo
3814.69GB
$14,495 / mo
1536 OpenAI text-embedding-3-small 572.20GB
$2,174 / mo|1430.51GB
$5,435 / mo
5722.04GB
$21,743 / mo
3072 OpenAI text-embedding-3-large 1144.40GB
$4,348 / mo|2861.02GB
$10,871 / mo
11444.09GB
$43,487 / mo

提高可扩展性

有几种方法可以应对嵌入扩展的挑战。最常见的方法是降维,比如使用
主成分分析 (PCA)
。然而,传统的降维方法——比如 PCA ——在处理嵌入时往往效果不佳。
最近,有关于
Matryoshka 表征学习
(MRL) 的新闻 (
博客
),这种方法由
OpenAI
使用,允许更经济的嵌入。使用 MRL 时,只使用前
n
个嵌入维度。这种方法已经被一些开源模型采用,比如
nomic-ai/nomic-embed-text-v1.5

mixedbread-ai/mxbai-embed-2d-large-v1
。对于 OpenAI 的
text-embedding-3-large
模型,我们看到在 12 倍压缩下性能保留了 93.1 %,而对于 nomic 的模型,在 3 倍压缩下保留了 95.8% 的性能,在 6 倍压缩下保留了 90% 的性能。

然而,还有一种新的方法可以在这个挑战上取得进展; 它不涉及降维,而是减少嵌入中每个个体值的尺寸大小:
量化
。我们的量化实验将展示,我们可以在显著加快计算速度并节省内存、存储和成本的同时,保持大量的性能。让我们进一步了解一下吧!

二进制量化

与在模型中减少权重精度的量化不同,嵌入的量化是指对嵌入本身进行的一个后处理步骤。特别是,二进制量化指的是将嵌入中的
float32
值转换为 1 bit ,从而在内存和存储使用上实现 32 倍的减少。

要将
float32
嵌入量化为二进制,我们只需将归一化的嵌入在 0 处进行阈值处理:

\[ f(x)=
\begin{cases}
0 & \text{如果 } x\leq 0\\
1 & \text{如果 } x \gt 0
\end{cases}
\]

我们可以使用汉明距离来高效地检索这些二进制嵌入。汉明距离是指两个二进制嵌入在位上不同的位置数量。汉明距离越低,嵌入越接近; 因此,文档的相关性越高。汉明距离的一个巨大优势是它可以用 2 个 CPU 周期轻松计算,允许极快的性能。

Yamada 等人 (2021)
引入了一个重打分步骤,他们称之为
rerank
,以提高性能。他们提议可以使用点积将
float32
查询嵌入与二进制文档嵌入进行比较。在实践中,我们首先使用二进制查询嵌入和二进制文档嵌入检索
rescore_multiplier * top_k
的结果——即双二进制检索的前 k 个结果的列表——然后使用
float32
查询嵌入对这个二进制文档嵌入列表进行重打分。

通过应用这种新颖的重打分步骤,我们能够在减少内存和磁盘空间使用 32 倍的同时,保留高达 ~96% 的总检索性能,并使检索速度提高多达 32 倍。如果没有重打分,我们能够保留大约 ~92.5% 的总检索性能。

Sentence Transformers 中的二进制量化

将一个维度为 1024 的嵌入量化为二进制将得到 1024 比特。实际上,将比特存储为字节要常见得多,因此当我们量化为二进制嵌入时,我们使用
np.packbits
将比特打包成字节。

因此,将一个维度为 1024 的
float32
嵌入量化后,得到一个维度为 128 的
int8

uint8
嵌入。下面是两种使用
Sentence Transformers
生成量化嵌入的方法:

from sentence_transformers import SentenceTransformer

# 1. Load an embedding model
model = SentenceTransformer("mixedbread-ai/mxbai-embed-large-v1")

# 2a. Encode some text using "binary" quantization
binary_embeddings = model.encode(
    ["I am driving to the lake.", "It is a beautiful day."],
    precision="binary",
)

或者

from sentence_transformers import SentenceTransformer
from sentence_transformers.quantization import quantize_embeddings

# 1. Load an embedding model
model = SentenceTransformer("mixedbread-ai/mxbai-embed-large-v1")

# 2b. or, encode some text without quantization & apply quantization afterwards
embeddings = model.encode(["I am driving to the lake.", "It is a beautiful day."])
binary_embeddings = quantize_embeddings(embeddings, precision="binary")

参考:

mixedbread-ai/mxbai-embed-large-v1
SentenceTransformer.encode
quantize_embeddings

在这里,你可以看到默认的
float32
嵌入和二进制嵌入在形状、大小和
numpy
数据类型方面的差异:

>>> embeddings.shape
(2, 1024)
>>> embeddings.nbytes
8192
>>> embeddings.dtype
float32
>>> binary_embeddings.shape
(2, 128)
>>> binary_embeddings.nbytes
256
>>> binary_embeddings.dtype
int8

请注意,你还可以选择
"ubinary"
来使用无符号的
uint8
数据格式将嵌入量化为二进制。这可能取决于你的向量库/数据库的要求。

向量数据库中的二进制量化

向量数据库 是否支持
Faiss
USearch
Vespa AI
Milvus
Qdrant 二进制量化
Weaviate 二进制量化

标量 (int8) 量化

我们使用标量量化过程将
float32
嵌入转换为
int8
。这涉及到将
float32
值的连续范围映射到可以表示 256 个不同级别 (从 -128 到 127) 的
int8
值的离散集合,如下面的图像所示。这是通过使用大量的嵌入校准数据集来完成的。我们计算这些嵌入的范围,即每个嵌入维度的
min

max
。从这里,我们计算将每个值分类的步骤 (桶)。

为了进一步提高检索性能,你可以可选地应用与二进制嵌入相同的重打分步骤。重要的是要注意,校准数据集极大地影响性能,因为它定义了量化桶。

Source: https://qdrant.tech/articles/scalar-quantization/

通过将标量量化为
int8
,我们将原始
float32
嵌入的精度降低,使得每个值都用一个 8 位整数表示 (缩小 4 倍)。请注意,这与二进制量化情况不同,在二进制量化中,每个值由一个单比特表示 (缩小 32 倍)。

Sentence Transformers 中的标量量化

将一个维度为 1024 的嵌入量化为
int8
将得到 1024 字节。在实际应用中,我们可以选择
uint8

int8
。这个选择通常取决于你的向量库/数据库支持哪种格式。

在实践中,建议为标量量化提供以下之一:

  1. 一大组嵌入,以便一次性全部量化,或者
  2. 每个嵌入维度的
    min

    max
    范围,或者
  3. 一大组嵌入的校准数据集,从中可以计算
    min

    max
    范围。

如果这些情况都不适用,你将收到如下警告:
Computing int8 quantization buckets based on 2 embeddings. int8 quantization is more stable with 'ranges' calculated from more embeddings or a 'calibration_embeddings' that can be used to calculate the buckets.
大意是如果你只使用很少量的嵌入 (在这个例子中是 2 个嵌入) 来计算这些量化桶,那么量化可能不会那么稳定或准确,因为少量的数据可能无法很好地代表整个数据分布。因此,如果你有一个很大的数据集来计算这些范围,或者有一个校准数据集,那么你可以得到更好的量化结果。

请看下面如何使用
Sentence Transformers
生成标量量化嵌入:

from sentence_transformers import SentenceTransformer
from sentence_transformers.quantization import quantize_embeddings
from datasets import load_dataset

# 1. Load an embedding model
model = SentenceTransformer("mixedbread-ai/mxbai-embed-large-v1")

# 2. Prepare an example calibration dataset
corpus = load_dataset("nq_open", split="train[:1000]")["question"]
calibration_embeddings = model.encode(corpus)

# 3. Encode some text without quantization & apply quantization afterwards
embeddings = model.encode(["I am driving to the lake.", "It is a beautiful day."])
int8_embeddings = quantize_embeddings(
    embeddings,
    precision="int8",
    calibration_embeddings=calibration_embeddings,
)

参考文献:

mixedbread-ai/mxbai-embed-large-v1
SentenceTransformer.encode
quantize_embeddings

在这里,你可以看到默认的
float32
嵌入和
int8
标量嵌入在形状、大小和
numpy
数据类型方面的差异:

>>> embeddings.shape
(2, 1024)
>>> embeddings.nbytes
8192
>>> embeddings.dtype
float32
>>> int8_embeddings.shape
(2, 1024)
>>> int8_embeddings.nbytes
2048
>>> int8_embeddings.dtype
int8

向量数据库中的标量量化

向量数据库 是否支持标量量化
Faiss IndexHNSWSQ
USearch
Vespa AI
OpenSearch
ElasticSearch
Milvus IVF_SQ8
Qdrant Scalar Quantization

结合二进制和标量量化

结合二进制和标量量化可以兼得两者的优点: 二进制嵌入的极快速度和标量嵌入在重打分后的优良性能的保留。请查看下面的
演示
,这是一个涉及维基百科 4100 万文本的真实实现。该设置的流程如下:

  1. 使用
    mixedbread-ai/mxbai-embed-large-v1
    SentenceTransformer 模型对查询进行嵌入。
  2. 使用
    sentence-transformers
    库中的
    quantize_embeddings
    函数将查询量化为二进制。
  3. 使用量化查询在二进制索引 (4100 万个二进制嵌入; 5.2GB 内存/磁盘空间) 中搜索前 40 个文档。
  4. 从磁盘上的 int8 索引 (4100 万个 int8 嵌入; 0 字节内存,47.5GB 磁盘空间) 动态加载前 40 个文档。
  5. 使用 float32 查询和 int8 嵌入对前 40 个文档进行重打分,以获得前 10 个文档。
  6. 按分数对前 10 个文档进行排序并显示。

通过这种方法,我们为索引使用了 5.2GB 内存和 52GB 磁盘空间。这比通常的检索所需的 200GB 内存和 200GB 磁盘空间要少得多。尤其是当你进一步扩展时,这将显著减少延迟和成本。

量化实验

我们在
MTEB
的检索子集上进行了实验,该子集包含 15 个基准测试。首先,我们使用
rescore_multiplier
为 4 来检索前 k (k=100) 个搜索结果。因此,我们总共检索了 400 个结果,并对这前 400 个结果进行了重打分。对于
int8
性能,我们直接使用了点积,而没有进行任何重打分。

模型 嵌入维度 250M 嵌入 MTEB 检索(NDCG@10) 默认性能的百分比
开源模型
mxbai-embed-large-v1
: float32
1024 953.67GB
$3623 / mo
54.39 100%
mxbai-embed-large-v1
: int8
1024 238.41GB
$905 / mo
52.79 97%
mxbai-embed-large-v1
: binary
1024 29.80GB
$113.25 / mo
52.46 96.45%
e5-base-v2
: float32
768 286.10GB
$1087 / mo
50.77 100%
e5-base-v2
: int8
768 178.81GB
$679 / mo
47.54 94.68%
e5-base-v2
: binary
768 22.35GB
$85 / mo
37.96 74.77%
all-MiniLM-L6-v2
: float32
384 357.62GB
$1358 / mo
41.66 100%
all-MiniLM-L6-v2
: int8
384 89.40GB
$339 / mo
37.82 90.79%
all-MiniLM-L6-v2
: binary
384 11.18GB
$42 / mo
39.07 93.79%
专有模型
Cohere-embed-english-v3.0
: float32
1024 953.67GB
$3623 / mo
55.0 100%
Cohere-embed-english-v3.0
: int8
1024 238.41GB
$905 / mo
55.0 100%
Cohere-embed-english-v3.0
: binary
1024 29.80GB
$113.25 / mo
52.3 94.6%

从我们的量化实验结果中,可以识别出几个关键趋势和好处。正如预期的那样,维度更高的嵌入模型通常每计算生成的存储成本更高,但能实现最佳性能。然而,令人惊讶的是,量化到
int8
已经帮助
mxbai-embed-large-v1

Cohere-embed-english-v3.0
在存储使用低于较小维度基模型的情况下实现了更高的性能。

量化好处的显现,在查看二进制模型的结果时更为明显。在这种情况下,1024 维度的模型仍然优于现在存储需求高 10 倍的基模型,而
mxbai-embed-large-v1
在资源需求减少 32 倍后仍能保持超过 96% 的性能。从
int8
进一步量化到二进制的性能损失几乎可以忽略不计。

有趣的是,我们还可以看到
all-MiniLM-L6-v2
在二进制量化上的性能比
int8
量化更强。这可能的原因是校准数据的选择。在
e5-base-v2
上,我们观察到了
维度坍缩
效应,导致模型只使用潜在空间的子空间; 当进行量化时,整个空间进一步坍缩,导致性能损失很大。

这表明量化并不适用于所有嵌入模型。考虑现有基准测试结果并开展实验以确定给定模型与量化的兼容性仍然至关重要。

重打分的影响

在本节中,我们探讨了重打分对检索性能的影响。我们基于
mxbai-embed-large-v1
评估了结果。

二进制重打分

使用二进制嵌入,
mxbai-embed-large-v1
在 MTEB 检索上保留了 92.53% 的性能。仅进行重打分而无需检索更多样本,性能提升到了 96.45%。我们实验设置了
rescore_multiplier
从 1 到 10,但没有观察到进一步的性能提升。这表明
top_k
搜索已经检索到了最顶级的候选项,而重打分则正确地重新排列了这些好的候选项。

标量 (Int8) 重打分

我们还评估了
mxbai-embed-large-v1
模型与
int8
重打分,因为 Cohere 表明
Cohere-embed-english-v3.0

int8
量化后可以达到
float32
模型的 100% 性能。在这个实验中,我们将
rescore_multiplier
设置为 [1, 4, 10],并得到了以下结果:

从图表中我们可以看到,更高的重打分乘数意味着量化后性能的更好保留。从我们的结果推断,我们假设这种关系可能是双曲线的,随着重打分乘数的增加,性能接近 100%。使用
int8
时,重打分乘数为 4-5 已经导致令人瞩目的 99% 的性能保留。

检索速度

我们使用 Google Cloud Platform 的
a2-highgpu-4g
实例,在整个 MTEB 检索中测量了
mxbai-embed-large-v1
嵌入的检索速度,该嵌入的维度为 1024。对于
int8
,我们使用了
USearch
(版本 2.9.2) 和二进制量化
Faiss
(版本 1.8.0)。所有计算都在 CPU 上使用精确搜索完成。

量化 最小 均值 最大
float32 1x (baseline) 1x
(baseline)
1x (baseline)
int8 2.99x speedup 3.66x
speedup
4.8x speedup
binary 15.05x speedup 24.76x
speedup
45.8x speedup

如表中所示,应用
int8
标量量化相比全尺寸
float32
嵌入实现了平均速度提升 3.66 倍。此外,二进制量化实现了平均速度提升 24.76 倍。对于标量和二进制量化,即使在最坏的情况下,也实现了非常显著的速度提升。

性能汇总

量化在资源使用、检索速度和检索性能方面的实验结果和影响可以总结如下:

float32 int8/uint8 binary/ubinary
内存和索引空间节省 1x 精确 4x 精确 32x
检索速度 1x 多达 4x 多达 45x
默认性能百分比 100% ~99.3% ~96%

演示

以下
演示
展示了通过结合二进制搜索和标量 (
int8
) 重打分来提高检索效率。该解决方案需要 5GB 的内存用于二进制索引和 50GB 的磁盘空间用于二进制和标量索引,这比常规的
float32
检索所需的 200GB 内存和磁盘空间要少得多。此外,检索速度也更快。

自己尝试

以下脚本可用于实验性地进行检索和其他用途的嵌入量化。它们分为三个类别:

未来工作

我们期待二进制量化技术的进一步发展。要提到的一些潜在改进,我们怀疑可能还有比
int8
更小的标量量化空间,即使用 128 或 64 个桶而不是 256 个。

此外,我们也很兴奋地发现,嵌入量化与 Matryoshka 表征学习 (MRL) 完全垂直。换句话说,可以将 MRL 嵌入从例如 1024 减少到 128 (通常与 2% 的性能降低相对应),然后应用二进制或标量量化。我们怀疑这可能会将检索速度提高多达 32 倍,质量降低约 3%,或者质量降低约 10% 时检索速度提高多达 256 倍。

最后,我们意识到,使用嵌入量化进行检索可以与一个独立的重新排序模型结合起来使用。我们设想了一个三步流水线: 首先进行二进制搜索,然后对结果进行标量 (int8) 重打分,最后使用交叉编码模型进行重新排序。这样的流水线可以实现高效的检索性能,同时降低延迟、内存使用、磁盘空间和成本。

致谢

这个项目得益于我们与
mixedbread.ai
的合作以及
SentenceTransformers
库,该库允许你轻松创建句子嵌入并进行量化。如果你想在你的项目中使用量化嵌入,你现在知道该怎么做了!

引用

@article{shakir2024quantization,
  author       = { Aamir Shakir and
                   Tom Aarsen and
                   Sean Lee
                 },
  title = { Binary and Scalar Embedding Quantization for Significantly Faster & Cheaper Retrieval },
  journal = {Hugging Face Blog},
  year = {2024},
  note = {https://huggingface.co/blog/embedding-quantization},
}

参考文献

mixedbread-ai/mxbai-embed-large-v1
SentenceTransformer.encode
quantize_embeddings


英文原文:
https://hf.co/blog/embedding-quantization
原文作者: Aamir Shakir, Tom Aarsen, SeanLee
译者: innovation64

前言

  • 我们已经介绍了radash的相关信息和部分Array相关方法,详情可前往主页查看;
  • 本篇我们继续介绍radash中Array的相关方法;
  • 下期我们将介绍解析radash中剩余的 Array相关方法,并整理出Array方法使用目录,包括
    文章说明

    脑图说明

Radash的Array相关方法详解

iterate:把一个函数迭代执行指定次数

  1. 使用说明
    • 参数:迭代次数、每次迭代调用的函数、迭代初始值。
    • 返回值:返回最终一次循环迭代的值。
  2. 使用代码示例
    import { iterate } from 'radash'
    
    const value = iterate(
      4,
      (acc, idx) => {
        return acc + idx
      },
      0
    ) // => 10
    
  3. 源码解析
    // 定义一个泛型函数 `iterate`,它接收三个参数:
    // `count` 是一个数字,表示迭代的次数;
    // `func` 是一个函数,它在每次迭代中被调用,接收当前值和迭代次数作为参数,并返回一个新的值;
    // `initValue` 是迭代的初始值,它的类型为泛型 `T`。
    export const iterate = <T>(
      count: number,
      func: (currentValue: T, iteration: number) => T,
      initValue: T
    ) => {
      // 初始化一个变量 `value`,用于存储当前的迭代值,起始值设置为 `initValue`。
      let value = initValue
      // 使用一个 `for` 循环进行 `count` 次迭代。循环变量 `i` 从 1 开始,以确保迭代次数正确。
      for (let i = 1; i <= count; i++) {
        // 在每次迭代中,调用函数 `func`,传入当前的 `value` 和迭代次数 `i`,
        // 然后将 `func` 函数返回的新值赋给 `value`,以便在下一次迭代中使用。
        value = func(value, i)
      }
      // 循环结束后,返回最终的迭代值 `value`。
      return value
    }
    

    • 方法流程说明:
      1. 接收
        count

        func

        initValue
        作为参数。
      2. 初始化变量
        value

        initValue
      3. 进行
        count
        次迭代,每次迭代中调用
        func
        函数,传入当前的
        value
        和迭代次数
        i
      4. func
        函数返回一个新值,这个新值成为下一次迭代的
        value
      5. 迭代完成后,返回最后一次
        func
        函数调用的结果。

last:输出数组的最后一项,如果数组为空则输出传入的默认值

  1. 使用说明


    • 参数:目标数组,或者空数组和默认值。
    • 返回值:数组最后一项,如果数组为空则输出传入的默认值。
  2. 使用代码示例

    import { last } from 'radash'
    
    const fish = ['marlin', 'bass', 'trout']
    
    const lastFish = last(fish) // => 'trout'
    const lastItem = last([], 'bass') // => 'bass'
    
  3. 源码解析

    // 定义一个泛型函数 `last`,它接收一个具有只读属性的泛型数组 `array`,
    // 和一个可选的默认值 `defaultValue`,其类型可以是泛型 `T` 或 `null` 或 `undefined`,默认值为 `undefined`。
    export const last = <T>(
      array: readonly T[],
      defaultValue: T | null | undefined = undefined
    ) => {
      // 如果数组存在且长度大于0,返回数组的最后一个元素。
      // 否则,返回提供的默认值 `defaultValue`。
      return array?.length > 0 ? array[array.length - 1] : defaultValue
    }
    

    • 方法流程说明:
      1. 检查传入的数组
        array
        是否存在且长度大于0。
      2. 如果数组存在且不为空(长度大于0),则返回数组的最后一个元素
        array[array.length - 1]
      3. 如果数组不存在或为空,返回
        defaultValue
      4. 这个函数对于需要安全地访问数组最后一个元素而不抛出错误的情况很有用,特别是在不确定数组是否为空的情况下。通过提供一个默认值,你可以避免在数组为空时访问未定义的索引。如果没有提供默认值,函数将默认返回
        undefined

list:创建包含特定项的数组

  1. 使用说明
    • 参数:start、end、值,步长。
    • 返回值:从start开始遍历到end,输出一个数组,包含特定项(值)的数组。
  2. 使用代码示例
    import { list } from 'radash'
    
    list(3)                  // [0, 1, 2, 3]
    list(0, 3)               // [0, 1, 2, 3]
    list(0, 3, 'y')          // [y, y, y, y]
    list(0, 3, () => 'y')    // [y, y, y, y]
    list(0, 3, i => i)       // [0, 1, 2, 3]
    list(0, 3, i => `y${i}`) // [y0, y1, y2, y3]
    list(0, 3, obj)          // [obj, obj, obj, obj]
    list(0, 6, i => i, 2)    // [0, 2, 4, 6]
    
  3. 源码解析
    // 定义一个泛型函数 `list`,它接受一个默认类型参数 `T`,默认为 `number`。
    // 函数接受四个参数:起始值或长度 `startOrLength`,可选的结束值 `end`,
    // 可选的值或映射函数 `valueOrMapper` 用于生成数组中的值,以及可选的步长 `step`。
    export const list = <T = number>(
      startOrLength: number,
      end?: number,
      valueOrMapper?: T | ((i: number) => T),
      step?: number
    ): T[] => {
      // 使用 `Array.from` 方法来创建一个数组,它接受 `range` 生成器函数作为参数。
      // `range` 函数根据提供的参数生成一个序列的值。
      return Array.from(range(startOrLength, end, valueOrMapper, step))
    }
    

    • 方法流程说明:
      1. 调用
        range
        函数,传入
        startOrLength
        (起始值或长度)、
        end
        (结束值)、
        valueOrMapper
        (值或映射函数)、
        step
        (步长)四个参数。这些参数都是可选的,除了
        startOrLength
        必须提供。
      2. range
        函数是一个生成器,根据提供的参数生成一个数字序列。如果指定了
        end
        ,那么
        startOrLength
        作为起始值,
        end
        作为结束值。如果没有指定
        end

        startOrLength
        作为序列的长度。
        valueOrMapper
        可以是一个值,此时序列中的每个元素都是这个值;也可以是一个函数,此时序列中的每个元素都是这个函数的返回值。
        step
        指定了序列中每个元素之间的间隔。
      3. Array.from
        方法用于从
        range
        生成器创建一个数组。生成器的每次迭代返回的值都会成为数组中的一个元素。
      4. 最终,
        list
        函数返回这个数组。

max:获取对象数组中指定标识符最大的项

  1. 使用说明
    • 参数:目标对象数组、指定标识符的回调函数。
    • 返回值:符合条件的对象。
  2. 使用代码示例
    import { max } from 'radash'
    
    const fish = [
      {
        name: 'Marlin',
        weight: 105,
        source: 'ocean'
      },
      {
        name: 'Bass',
        weight: 8,
        source: 'lake'
      },
      {
        name: 'Trout',
        weight: 13,
        source: 'lake'
      }
    ]
    
    max(fish, f => f.weight) // => {name: "Marlin", weight: 105, source: "ocean"}
    
  3. 源码解析
    // 定义一个泛型函数 `max`,它接受一个具有只读属性的泛型数组 `array`,
    // 以及一个可选的函数 `getter`,该函数用于从数组元素中提取一个数字用于比较大小。
    export function max<T>(
      array: readonly T[],
      getter?: (item: T) => number
    ): T | null {
      // 如果 `getter` 函数未提供,则使用默认函数,它将元素作为其自己的值。
      const get = getter ?? ((v: any) => v)
      // 调用 `boil` 函数,传入数组和一个比较函数,
      // 比较函数用 `get` 函数获取的值来决定哪个元素更大。
      return boil(array, (a, b) => (get(a) > get(b) ? a : b))
    }
    
    // 定义一个泛型函数 `boil`,它接受一个具有只读属性的泛型数组 `array`,
    // 以及一个比较函数 `compareFunc`,该函数用于比较两个元素并返回其中一个。
    export const boil = <T>(
      array: readonly T[],
      compareFunc: (a: T, b: T) => T
    ) => {
      // 如果传入的数组不存在,或者其长度为0,则返回 null。
      if (!array || (array.length ?? 0) === 0) return null
      // 使用数组的 `reduce` 方法应用 `compareFunc`,将数组归约为单一的值。
      return array.reduce(compareFunc)
    }
    

    • 方法流程说明:
      1. 接收一个数组
        array
        和一个可选的
        getter
        函数。如果
        getter
        函数提供,它将用于从每个元素中提取用于比较的数字值。
      2. 如果没有提供
        getter
        函数,使用一个默认的函数,这个函数简单地返回元素本身作为比较值。
      3. 调用
        boil
        函数,传入数组和一个比较函数。这个比较函数使用
        get
        函数从两个元素
        a

        b
        中提取值,并返回较大值对应的元素。
      4. boil
        函数通过
        reduce
        方法遍历数组,应用比较函数,并最终返回单一的元素,即数组中的最大元素。如果数组为空或未定义,
        boil
        函数返回
        null
      5. max
        函数最终返回
        boil
        函数的结果,即数组中的最大元素,如果数组为空,则返回
        null

merge:合并数组中符合条件的项,并且会覆盖第一个数组

  1. 使用说明


    • 参数:数组1、数组2、条件函数。
    • 返回值:合并覆盖后的数组。
  2. 使用代码示例

    import { merge } from 'radash'
    
    const gods = [
      {
        name: 'Zeus',
        power: 92
      },
      {
        name: 'Ra',
        power: 97
      }
    ]
    
    const newGods = [
      {
        name: 'Zeus',
        power: 100
      }
    ]
    
    merge(gods, newGods, f => f.name) // => [{name: "Zeus" power: 100}, {name: "Ra", power: 97}]
    
  3. 源码解析

    export const merge = <T>(
      root: readonly T[],
      others: readonly T[],
      matcher: (item: T) => any
    ) => {
      if (!others && !root) return []
      if (!others) return root
      if (!root) return []
      if (!matcher) return root
      return root.reduce((acc, r) => {
        const matched = others.find(o => matcher(r) === matcher(o))
        if (matched) acc.push(matched)
        else acc.push(r)
        return acc
      }, [] as T[])
    }
    

    • 方法流程说明:
      1. 进行一系列的检查,如果
        others

        root
        两个数组都不存在或为空,或者没有提供
        matcher
        函数,就返回
        root
        或者一个空数组。
      2. 使用
        root
        数组的
        reduce
        方法构建最终的合并数组。在每次迭代中,使用
        matcher
        函数检查
        root
        数组中的当前元素是否在
        others
        数组中有匹配的元素。
      3. 如果在
        others
        数组中找到了一个匹配的元素,就把这个匹配的元素添加到累加器数组
        acc
        中。
      4. 如果没有找到匹配的元素,就把
        root
        数组中的当前元素添加到累加器
        acc
        中。
      5. 继续处理
        root
        数组的下一个元素,直到所有元素都被处理完毕。
      6. 返回累加器
        acc
        ,它现在包含了合并后的元素。

min:获取对象数组中指定标识符最小的项

  1. 使用说明
    • 参数:目标对象数组、指定标识符的条件函数。
    • 返回值:符合条件的的项
  2. 使用代码示例
    import { min } from 'radash'
    
    const fish = [
      {
        name: 'Marlin',
        weight: 105,
        source: 'ocean'
      },
      {
        name: 'Bass',
        weight: 8,
        source: 'lake'
      },
      {
        name: 'Trout',
        weight: 13,
        source: 'lake'
      }
    ]
    
    min(fish, f => f.weight) // => {name: "Bass", weight: 8, source: "lake"}
    
  3. 源码解析
    // 这是 `min` 函数的第一种声明,它要求传递一个 `getter` 函数。
    export function min<T>(
      array: readonly T[],
      getter: (item: T) => number
    ): T | null
    
    // 这是 `min` 函数的第二种声明,它允许 `getter` 函数是可选的。
    export function min<T>(
      array: readonly T[],
      getter?: (item: T) => number
    ): T | null {
      // 如果没有提供 `getter` 函数,使用默认函数,它将元素作为其自己的值。
      const get = getter ?? ((v: any) => v)
      // 调用 `boil` 函数,传入数组和一个比较函数,
      // 比较函数用 `get` 函数获取的值来决定哪个元素更小。
      return boil(array, (a, b) => (get(a) < get(b) ? a : b))
    }
    

    • 方法流程说明:
      1. 函数接收一个数组
        array
        和一个可选的
        getter
        函数。如果提供了
        getter
        函数,它将用于从每个元素中提取用于比较的数字值。
      2. 如果没有提供
        getter
        函数,则使用一个默认的函数,这个函数简单地返回元素本身作为比较值。
      3. 调用
        boil
        函数,传入数组和一个比较函数。这个比较函数使用
        get
        函数从两个元素
        a

        b
        中提取值,并返回较小值对应的元素。
      4. boil
        函数通过
        reduce
        方法遍历数组,应用比较函数,并最终返回单一的元素,即数组中的最小元素。如果数组为空或未定义,
        boil
        函数返回
        null
      5. min
        函数最终返回
        boil
        函数的结果,即数组中的最小元素,如果数组为空,则返回
        null

objectify:根据函数映射的键与值把数组转换为字典对象

  1. 使用说明
    • 参数:目标对象数组、条件函数1用于提取键、[条件函数2用于提取值]
    • 返回值:字典对象
  2. 使用代码示例
    import { objectify } from 'radash'
    
    const fish = [
      {
        name: 'Marlin',
        weight: 105
      },
      {
        name: 'Bass',
        weight: 8
      },
      {
        name: 'Trout',
        weight: 13
      }
    ]
    
    objectify(fish, f => f.name) // => { Marlin: [marlin object], Bass: [bass object], ... }
    objectify(
      fish,
      f => f.name,
      f => f.weight
    ) // => { Marlin: 105, Bass: 8, Trout: 13 }
    
  3. 源码解析
    // 定义一个泛型函数 `objectify`,它接受三个参数:
    // `array` 是一个具有只读属性的泛型数组,
    // `getKey` 是一个函数,用于从数组元素中提取键,
    // `getValue` 是一个可选的函数,用于从数组元素中提取值,默认情况下,它会返回元素本身作为值。
    export const objectify = <T, Key extends string | number | symbol, Value = T>(
      array: readonly T[],
      getKey: (item: T) => Key,
      getValue: (item: T) => Value = item => item as unknown as Value
    ): Record<Key, Value> => {
      // 使用数组的 `reduce` 方法来累积一个对象,该对象将数组元素映射为键值对。
      return array.reduce((acc, item) => {
        // 使用 `getKey` 函数从当前元素 `item` 中提取键,并使用 `getValue` 函数提取值。
        // 将这个键值对添加到累加器对象 `acc` 中。
        acc[getKey(item)] = getValue(item)
        // 返回更新后的累加器 `acc`。
        return acc
      }, {} as Record<Key, Value>) // 初始化累加器 `acc` 为一个空对象。
    }
    

    • 方法流程说明:
      1. 函数接收一个数组
        array
        ,一个
        getKey
        函数用于提取每个元素的键,以及一个可选的
        getValue
        函数用于提取每个元素的值。
      2. 如果没有提供
        getValue
        函数,则使用一个默认的函数,这个函数简单地将元素本身作为值。
      3. 使用
        reduce
        方法遍历数组。
        reduce
        方法的累加器
        acc
        是一个对象,用于存储键值对。
      4. 对于数组中的每个元素
        item
        ,使用
        getKey
        函数提取键,并使用
        getValue
        函数提取值。
      5. 将提取的键和值作为一个键值对添加到累加器对象
        acc
        中。
      6. 继续处理数组的下一个元素,直到所有元素都被处理完毕。
      7. 返回累加器
        acc
        ,它现在是一个完整的对象,包含了从数组元素映射而来的键值对。

range:根据步长生成一个数值范围内的迭代值

  1. 使用说明
    • 参数:起始值、[结束值]、[迭代函数]、步长。
    • 返回值:用在for循环中循环输出处理的结果值。
  2. 使用代码示例
    import { range } from 'radash'
    
    range(3)                  // yields 0, 1, 2, 3
    range(0, 3)               // yields 0, 1, 2, 3
    range(0, 3, 'y')          // yields y, y, y, y
    range(0, 3, () => 'y')    // yields y, y, y, y
    range(0, 3, i => i)       // yields 0, 1, 2, 3
    range(0, 3, i => `y${i}`) // yields y0, y1, y2, y3
    range(0, 3, obj)          // yields obj, obj, obj, obj
    range(0, 6, i => i, 2)    // yields 0, 2, 4, 6
    
    for (const i of range(0, 200, 10)) {
      console.log(i) // => 0, 10, 20, 30 ... 190, 200
    }
    
    for (const i of range(0, 5)) {
      console.log(i) // => 0, 1, 2, 3, 4, 5
    }
    
  3. 源码解析
    // 定义一个泛型生成器函数 `range`,它接受一个默认类型参数 `T`,默认为 `number`。
    // 函数接受四个参数:起始值或长度 `startOrLength`,可选的结束值 `end`,
    // 可选的值或映射函数 `valueOrMapper` 用于生成序列中的值,以及可选的步长 `step`,默认为 1。
    export function* range<T = number>(
      startOrLength: number,
      end?: number,
      valueOrMapper: T | ((i: number) => T) = i => i as T,
      step: number = 1
    ): Generator<T> {
      // 确定 `valueOrMapper` 是一个函数还是一个固定值。如果是函数,则直接使用;如果不是,则创建一个总是返回该值的函数。
      const mapper = isFunction(valueOrMapper) ? valueOrMapper : () => valueOrMapper
      // 如果提供了 `end` 值,则 `start` 为 `startOrLength`;如果没有提供 `end` 值,则 `start` 默认为 0。
      const start = end ? startOrLength : 0
      // `final` 是序列的结束值,如果提供了 `end` 值,则使用它;如果没有,则 `final` 为 `startOrLength`。
      const final = end ?? startOrLength
      // 从 `start` 开始,到 `final` 结束,每次迭代增加 `step`。
      for (let i = start; i <= final; i += step) {
        // 使用 `yield` 关键字产生由 `mapper` 函数处理的当前迭代值 `i`。
        yield mapper(i)
        // 如果下一次增加步长后的值会超过 `final`,则提前终止循环。
        if (i + step > final) break
      }
    }
    

    • 方法流程说明:
      1. 确定
        valueOrMapper
        参数是一个函数还是一个值。如果是函数,直接用作映射器;如果是值,创建一个总是返回该值的函数作为映射器。
      2. 确定起始值
        start
        。如果提供了
        end
        参数,
        start

        startOrLength
        ;否则
        start
        默认为 0。
      3. 确定结束值
        final
        。如果提供了
        end
        参数,
        final

        end
        ;否则
        final

        startOrLength
      4. 使用一个
        for
        循环从
        start
        遍历到
        final
        ,每次迭代增加步长
        step
      5. 在每次迭代中,使用
        yield
        关键字来产出
        mapper
        函数处理过的当前值。
      6. 如果在下一次迭代步长加上当前值
        i
        会超过
        final
        ,则提前退出循环。
      7. 提示:
        这个
        range
        函数提供了类似于 Python 中的
        range
        函数的功能,允许在迭代中产生一系列的值。通过使用生成器,我们可以惰性地产生这些值,这意味着直到迭代器被消费时,这些值才会被计算和产生。这在处理大范围的值时非常有用,因为它不需要一次性将所有值加载到内存中。

replaceOrAppend:替换对象数组中的项或是追加项(条件函数不满足时追加);

  1. 使用说明


    • 参数:被替换数组、用来的替换的数组、条件函数。
    • 返回值:替换或者追加后的数组。
  2. 使用代码示例

    import { replaceOrAppend } from 'radash'
    
    const fish = [
      {
        name: 'Marlin',
        weight: 105
      },
      {
        name: 'Salmon',
        weight: 19
      },
      {
        name: 'Trout',
        weight: 13
      }
    ]
    
    const salmon = {
      name: 'Salmon',
      weight: 22
    }
    
    const sockeye = {
      name: 'Sockeye',
      weight: 8
    }
    
    replaceOrAppend(fish, salmon, f => f.name === 'Salmon') // => [marlin, salmon (weight:22), trout]
    replaceOrAppend(fish, sockeye, f => f.name === 'Sockeye') // => [marlin, salmon, trout, sockeye]
    
  3. 源码解析

    // 定义一个泛型函数 `replaceOrAppend`,它接受三个参数:
    // `list` 是一个具有只读属性的泛型数组,
    // `newItem` 是一个新的元素,将被添加到数组中,
    // `match` 是一个函数,用于确定新元素应该替换数组中的哪个现有元素。
    export const replaceOrAppend = <T>(
      list: readonly T[],
      newItem: T,
      match: (a: T, idx: number) => boolean
    ) => {
      // 如果 `list` 和 `newItem` 都不存在或为空,则返回一个空数组。
      if (!list && !newItem) return []
      // 如果 `newItem` 不存在或为空,则返回 `list` 数组的副本。
      if (!newItem) return [...list]
      // 如果 `list` 不存在或为空,则返回一个只包含 `newItem` 的数组。
      if (!list) return [newItem]
      // 遍历 `list` 数组,寻找一个匹配的元素。
      for (let idx = 0; idx < list.length; idx++) {
        const item = list[idx]
        // 如果 `match` 函数返回 `true`,则在该位置替换元素。
        if (match(item, idx)) {
          // 创建一个新数组,其中包含从 `list` 开始到匹配位置之前的所有元素,
          // 然后是 `newItem`,然后是从匹配位置之后到 `list` 结束的所有元素。
          return [
            ...list.slice(0, idx),
            newItem,
            ...list.slice(idx + 1, list.length)
          ]
        }
      }
      // 如果没有找到匹配的元素,将 `newItem` 追加到 `list` 数组的末尾。
      return [...list, newItem]
    }
    

    • 方法流程说明:
      1. 进行一系列的检查,如果
        list

        newItem
        都不存在或为空,或者只有
        newItem
        不存在或为空,或者只有
        list
        不存在或为空,则返回相应的结果。
      2. 如果
        list

        newItem
        都存在,遍历
        list
        数组,对于每个元素和它的索引,调用
        match
        函数。
      3. 如果
        match
        函数对某个元素返回
        true
        ,说明找到了应该被替换的元素。函数将创建一个新数组,该数组由以下部分组成:从
        list
        的开始到匹配元素之前的部分,
        newItem
        ,以及从匹配元素之后到
        list
        结尾的部分。
      4. 如果遍历完成后没有找到任何匹配的元素,函数将
        newItem
        添加到
        list
        的末尾,并返回新数组。

下期我们将介绍 radash 中剩余的数组相关方法

  • replace :查找指定项,并用传入的去替换;
  • select :对数组同时进行过滤和映射,输出映射后的value数组;
  • shift :把目标数组向右移动 n 个位置返回为一个新数组;
  • sift:过滤调列表中值为false的项,返回剩余为true的项组成的数组;
  • sort :把数组按照条件函数指定的项的数值大小排序,支持升序和降序;
  • sum:数组对象根据条件函数指定想的数组求和;
  • toggle:查找数组中是否有我们给定的项,有则删除,没有则添加;
  • unique:数组去重,去除数组中重复的项;
  • zipToObject:将第一个数组中的键映射到第二个数组中对应的值;
  • zip:把两个数组变为二维数组,二维数组中的每个数组包含两个项分别为两个传入数组的相同位置的项。

写在后面

  • 后续作者会整理一份
    radash
    完整方法目录上传,方便没法访问外网的朋友查看使用。
  • 下期方法分享完后,会同步整理分享Array方法的使用说明目录,方便大家查阅对照使用。
  • 小tips:不出意外的话博主每周五会定时更新,出意外的话就是出意外了。
  • 大家有任何问题或见解,欢迎评论区留言交流和批评指正!!!
  • 点击访问:
    radash官网

感谢大家对园子周边的支持!今天分享一个周边网店运营的小插曲。

为了方便购买园子的
周边商品
,我们开起了「博客园」淘宝店,虽然是网店运营小白,但我们边学边做, 认认真真、规规矩矩地运营着园子的淘宝店,而且不依赖淘宝的流量,不依赖淘宝的物流,只是作为购买园子周边的下单平台,即使这样,还是被智能的淘宝莫名其妙地处罚了。

4月9日,我们收到一封淘宝的通知邮件:

邮件标题:您收到一条处罚消息!如有疑义请及时申诉!

邮件内容:亲爱的博客园商店,您收到一条交易资金保障(账期延长15天)的处罚消息,违规编号37684560463;建议您根据违规原因自检自查,如有疑义,请在2024-04-24 08:14内进行申诉,并在规定时间内完成举证。

又是“处罚”,又是“自检自查”,这些用词吓的我们赶紧联系客服,然后体会到了淘宝如此智能,如此为买家作想。

客服回复说:

这是一个预防性的违规,担心买家收到资损,这个违规不是说商家有责任哈,就是核实交易平台担心有风险,所以暂时延长货款到账时间,钱不会少的,放心亲亲,等账期延长结束,钱款会正常到账的

商家没有责任,只是因为淘宝担心买家收到资损,所以就无条件地暂时延长账期,虽然账期延长时间不是很长,但这种做法实在让人难以理解。

为了减少这样的单点故障,我们决定借鉴技术领域的解决之道——负载均衡,在多个平台开店。

之前已经开了微店,本来微店只是作为备用节点,方便通过微信购买,现在需要升级为负载均衡中的一员。

接下来再找找其他平台,如果起步成本可以接受,会考虑京东。

另外,我们也开始自己开发在线购买周边商品的功能,这样以后可以通过园子的网站
https://cnblogs.vip/store
直接购买周边。

JS解混淆

最近在整理之前和一些同伴的分享资料,发现时间已经过了好久,特此整理一些有价值的分享记录。

JS混淆

学习js混淆可以逆向分析混淆和加密过程,实战可用于爬虫和渗透信息获取

本文档用于初步介绍js混淆的基础概念以及如何解混淆、调试,便于干掉反爬虫和渗透信息收集思路拓展

概念解释

混淆/加密

降低代码可读性加强安全性,防止被人任意查看,在一定程度保护资源

理想的混淆或加密应该具备如下特点

1、没有确定的破解模式;

2、很难编制自动破解程序(只能手工破解);

3、破解过程繁琐、耗时;

4、“混淆|加密”后的代码,比原始代码长度增加少;

代码里诸如此类就是经过了混淆的结果,可以通过console+断点打出来看看值

image-20220519164011809

image-20220519164047706

js混淆和eval加密

前端虽然开源, 但是由于前端代码量很多,也有一些特殊的保护代码的方法

其中Eval、js混淆是常用的方式,但是在大的互联网产品上用得很少,因为前端加密(RSA、AES、MD5等)是为了保证数据传输中的安全性,而非要让人难以模仿数据传输请求

而前端中的js混淆、eval对于专业的人来说形同虚设,所以也没必要做混淆和eval,并且对于代码维护是及其不利的

eval加密

js中的eval()方法就是一个js语言的执行器

它能把其中的参数按照JavaScript语法进行解析并执行

简单来说就是把原本的js代码变成了eval的参数,变成参数后代码就成了字符串,其中的一些字符就会被按照特定格式“编码”

是最早JS出现的混淆加密,据说第一天就被破解,修改一下代码,alert一下就可以破解了

#eval加密

源代码:
var showmsg="粘贴要加密/解密的javascript代码到这里";
if(1==1){
  alert(showmsg);
}

加密后的样子:
eval(function(p,a,c,k,e,d){e=function(c)
{return(c<a?'':e(parseInt(c/a)))+((c=c%a)>35?
String.fromCharCode(c+29):c.toString(36))};
if(!''.replace(/^/,String)){while(c--)d[e(c)]=k[c]||e(c);k=[function(e){return 
d[e]}];e=function(){return'\\w+'};c=1};while(c--)if(k[c])p=p.replace(new 
RegExp('\\b'+e(c)+'\\b','g'),k[c]);return p}('5 4="粘贴要加密/解密的3代码到这里";2(0==0){  
1(4);}',62,6,'1|alert|if|javascript|showmsg|var'.split('|'),0,{}))

eval()语句还有一个重要用途:在反调试中可以使用该语句来进行一些函数值赋空从而跳出debugger的函数

JS混淆

把其中的变量、方法位置顺序打乱,但是又用一些无关的变量或者方法来保证执行顺序

常见手段

1、去除缩进、空行、换行、注释

2、变量名替换(缩短/改乱)

3、通过自定义变量名引用JS关键字

4、添加大段空白,增加代码前后间隔,干扰阅读

5、混眼法(通过利用[]和["、']及变量定义语句来添加与代码功能无关的字符/增添与代码功能无关的运算语句)

6、对源代码进行加密,同时附上解密的代码(运行时先解密,然后通过document.write()或eval()或innerHTML把代码释放出来执行)

其他混淆类型

hash类型

压缩类型

常用工具

混淆

这里是从使用工具加密信息方出发,具体工具的使用可以自行学习。

  • webassembly
  • esprima

针对JavaScript

  • JavaScript Obfuscator

​ 具体使用参考:
7.8k Star!一个强大的 JS 代码混淆工具 - 掘金 (juejin.cn)

  • terser
  • uglify-js
  • uglify-es
  • Google Closure Compiler
  • YUI Compressor

针对CSS

  • PostCSS

  • clean-css

  • CSSO

  • YUI Compressor

针对HTML

  • html-minifier

混淆示例

此处使用
JavaScript Obfuscator Tool
,由JavaScript Obfuscator作者搭建的一个在线混淆网站,直接输入需要混淆的代码输出混淆结果即可

以下面的一个简单hello world为例

##源代码
function hi() {
  console.log("Hello World!");
}
hi();

经过混淆之后

##混淆后的代码
(function(_0x1522cf,_0x263348){var _0x2bf84c=_0x42bb,_0x47bae4=_0x1522cf();while(!![]){try{var _0x301f10=parseInt(_0x2bf84c(0x11b))/0x1*(-parseInt(_0x2bf84c(0x10f))/0x2)+-parseInt(_0x2bf84c(0x114))/0x3+parseInt(_0x2bf84c(0x112))/0x4*(-parseInt(_0x2bf84c(0x117))/0x5)+-parseInt(_0x2bf84c(0x110))/0x6+parseInt(_0x2bf84c(0x115))/0x7*(parseInt(_0x2bf84c(0x118))/0x8)+parseInt(_0x2bf84c(0x119))/0x9*(parseInt(_0x2bf84c(0x116))/0xa)+parseInt(_0x2bf84c(0x11a))/0xb*(parseInt(_0x2bf84c(0x113))/0xc);if(_0x301f10===_0x263348)break;else _0x47bae4['push'](_0x47bae4['shift']());}catch(_0x2af3c3){_0x47bae4['push'](_0x47bae4['shift']());}}}(_0x22dc,0x1e93e));function hi(){var _0xfdbe99=_0x42bb;console[_0xfdbe99(0x666666)]('Hello\x20World!');}hi();function _0x42bb(_0x4a56bb,_0x17e1ee){var _0x22dca2=_0x22dc();return _0x42bb=function(_0x42bb1c,_0x597cba){_0x42bb1c=_0x42bb1c-0x10f;var _0x2ad529=_0x22dca2[_0x42bb1c];return _0x2ad529;},_0x42bb(_0x4a56bb,_0x17e1ee);}function _0x22dc(){var _0x1ca681=['937926xGdCzf','log','344SUuAGG','1124988WMYeGw','666666081MLZhWo','35SqOFWp','670aFpiLz','12820fkuEha','108152xzQqbd','15975Prsnjz','44YZHRMa','1oaFebR','44836HvkwgV'];_0x22dc=function(){return _0x1ca681;};return _0x22dc();}

##为了展示直观,经过代码美化处理结果如下
(function(_0x1522cf, _0x263348) {
	var _0x2bf84c = _0x42bb,
		_0x47bae4 = _0x1522cf();
	while (!![]) {
		try {
			var _0x301f10 = parseInt(_0x2bf84c(0x11b)) / 0x1 * (-parseInt(_0x2bf84c(0x10f)) / 0x2) + -parseInt(_0x2bf84c(0x114)) / 0x3 + parseInt(_0x2bf84c(0x112)) / 0x4 * (-parseInt(_0x2bf84c(0x117)) / 0x5) + -parseInt(_0x2bf84c(0x110)) / 0x6 + parseInt(_0x2bf84c(0x115)) / 0x7 * (parseInt(_0x2bf84c(0x118)) / 0x8) + parseInt(_0x2bf84c(0x119)) / 0x9 * (parseInt(_0x2bf84c(0x116)) / 0xa) + parseInt(_0x2bf84c(0x11a)) / 0xb * (parseInt(_0x2bf84c(0x113)) / 0xc);
			if (_0x301f10 === _0x263348) break;
			else _0x47bae4['push'](_0x47bae4['shift']());
		} catch (_0x2af3c3) {
			_0x47bae4['push'](_0x47bae4['shift']());
		}
	}
}(_0x22dc, 0x1e93e));

function hi() {
	var _0xfdbe99 = _0x42bb;
	console[_0xfdbe99(0x666666)]('Hello\x20World!');
}
hi();

function _0x42bb(_0x4a56bb, _0x17e1ee) {
	var _0x22dca2 = _0x22dc();
	return _0x42bb = function(_0x42bb1c, _0x597cba) {
		_0x42bb1c = _0x42bb1c - 0x10f;
		var _0x2ad529 = _0x22dca2[_0x42bb1c];
		return _0x2ad529;
	}, _0x42bb(_0x4a56bb, _0x17e1ee);
}

function _0x22dc() {
	var _0x1ca681 = ['937926xGdCzf', 'log', '344SUuAGG', '1124988WMYeGw', '666666081MLZhWo', '35SqOFWp', '670aFpiLz', '12820fkuEha', '108152xzQqbd', '15975Prsnjz', '44YZHRMa', '1oaFebR', '44836HvkwgV'];
	_0x22dc = function() {
		return _0x1ca681;
	};
	return _0x22dc();
}

可以发现代码混淆有几个比较固定的特征,一些变量的命名会赋随机值,而后通过一个数组去进行存储。同时使用一个while-try-catch的结构。

再看一下实际环境中经过混淆的代码

// 此处也是经过格式美化,源代码只有一行
eval(function(p, a, c, k, e, d) {
	e = function(c) {
		return (c < a ? "" : e(parseInt(c / a))) + ((c = c % a) > 35 ? String.fromCharCode(c + 29) : c.toString(36))
	};
	if (!''.replace(/^/, String)) {
		while (c--) d[e(c)] = k[c] || e(c);
		k = [function(e) {
			return d[e]
		}];
		e = function() {
			return '\\w+'
		};
		c = 1;
	};
	while (c--)
		if (k[c]) p = p.replace(new RegExp('\\b' + e(c) + '\\b', 'g'), k[c]);
	return p;
}('4 3(1){2 0=5 8();7 0.6(1)}', 9, 9, 'b|tksl|var|dswejwehxt|function|new|decode|return|Base64'.split('|'), 0, {}));

// respond
eval(function(p, a, c, k, e, d) {
	e = function(c) {
		return (c < a ? "" : e(parseInt(c / a))) + ((c = c % a) > 35 ? String.fromCharCode(c + 29) : c.toString(36))
	};
	if (!''.replace(/^/, String)) {
		while (c--) d[e(c)] = k[c] || e(c);
		k = [function(e) {
			return d[e]
		}];
		e = function() {
			return '\\w+'
		};
		c = 1;
	};
	while (c--)
		if (k[c]) p = p.replace(new RegExp('\\b' + e(c) + '\\b', 'g'), k[c]);
	return p;
}('(c(w){"2O 2E";8 7={};w.7=7;7.21=c(){};8 V=[],1T=(c(){8 16=1E;2t{16=1l w.2s()}2u(e){16=1l w.2w("2v.2o")}l c(){l 16}})(),14=c(1t,22){8 p=1T();5(!p){l}p.2n("2p",1t,1f);p.2r=c(){5(p.1Q!==4||p.2a!==2q&&p.2a!==2D){l}22(p.2C)};5(p.1Q===4){l}p.2G(1b)},1J=c(25){l 25.U(7.f.2g,\'\').I(7.f.1R)};7.14=14;7.2F=V;7.2y=1J;7.f={b:/@b[^\\{]+\\{([^\\{\\}]*\\{[^\\}\\{]*\\})+/17,1j:/@(?:\\-(?:o|2x|2z)\\-)?1j[^\\{]+\\{(?:[^\\{\\}]*\\{[^\\}\\{]*\\})+[^\\}]*\\}/17,2f:/\\/\\*[^*]*\\*+([^/][^*]*\\*+)*\\//17,20:/(1t\\()[\'"]?([^\\/\\)\'"][^:\\)\'"]+)[\'"]?(\\))/g,1U:/@b *([^\\{]+)\\{([\\S\\s]+?)$/,Y:/(Y\\s+)?([a-2e-Z]+)\\s?/,12:/\\(\\s*v\\-19\\s*:\\s*(\\s*[0-9\\.]+)(1o|E)\\s*\\)/,15:/\\(\\s*u\\-19\\s*:\\s*(\\s*[0-9\\.]+)(1o|E)\\s*\\)/,2g:/\\(\\s*m(1h|2B)\\-(2A|19)\\s*:\\s*(\\s*[0-9\\.]+)(1o|E)\\s*\\)/17,1R:/\\([^\\)]*\\)/g};7.2b=w.1s&&w.1s("Y 1M")!==1b&&w.1s("Y 1M").2k;5(7.2b){l}8 h=w.2j,t=h.2m,X=[],F=[],B=[],1i={},1w=30,G=h.1H("G")[0]||t,1z=h.1H("1z")[0],W=G.1H("2l"),1a,1p,1c,T=c(){8 Q,H=h.1k(\'H\'),d=h.d,29=t.q.J,1n=d&&d.q.J,1d=1E;H.q.26="2i:2H;34-33:1Z;19:1Z";5(!d){d=1d=h.1k("d");d.q.36="35"}t.q.J="1S%";d.q.J="1S%";d.27(H);5(1d){t.23(d,t.2Z)}Q=H.2Y;5(1d){t.1r(d)}K{d.1r(H)}t.q.J=29;5(1n){d.q.J=1n}Q=1c=10(Q);l Q},18=c(1X){8 1C="32",1m=t[1C],1u=h.31==="3c"&&1m||h.d[1C]||1m,C={},28=W[W.y-1],1v=(1l 3a()).37();5(1X&&1a&&1v-1a<1w){w.38(1p);1p=w.2d(18,1w);l}K{1a=1v}N(8 i 1h X){5(X.1q(i)){8 z=X[i],v=z.12,u=z.15,1y=v===1b,1x=u===1b,E="E";5(!!v){v=10(v)*(v.1D(E)>-1?(1c||T()):1)}5(!!u){u=10(u)*(u.1D(E)>-1?(1c||T()):1)}5(!z.1Y||(!1y||!1x)&&(1y||1u>=v)&&(1x||1u<=u)){5(!C[z.b]){C[z.b]=[]}C[z.b].M(F[z.F])}}}N(8 j 1h B){5(B.1q(j)){5(B[j]&&B[j].2N===G){G.1r(B[j])}}}B.y=0;N(8 k 1h C){5(C.1q(k)){8 A=h.1k("q"),L=C[k].2P("\\n");A.2J="2I/L";A.b=k;G.23(A,28.2K);5(A.R){A.R.26=L}K{A.27(h.2V(L))}B.M(A)}}},1B=c(P,6,b){8 11=P.U(7.f.2f,\'\').U(7.f.1j,\'\').I(7.f.b),1e=11&&11.y||0;6=6.1O(0,6.2U("/"));8 1N=c(L){l L.U(7.f.20,"$1"+6+"$2$3")},1L=!1e&&b;5(6.y){6+="/"}5(1L){1e=1}N(8 i=0;i<1e;i++){8 1g,D,13,1K;5(1L){1g=b;F.M(1N(P))}K{1g=11[i].I(7.f.1U)&&r.$1;F.M(r.$2&&1N(r.$2))}13=1g.1A(",");1K=13.y;N(8 j=0;j<1K;j++){D=13[j];5(1J(D)){2W}X.M({b:D.1A("(")[0].I(7.f.Y)&&r.$2||"1M",F:F.y-1,1Y:D.1D("(")>-1,12:D.I(7.f.12)&&10(r.$1)+(r.$2||""),15:D.I(7.f.15)&&10(r.$1)+(r.$2||"")})}}18()},1I=c(){5(V.y){8 O=V.2R();14(O.6,c(P){1B(P,O.6,O.b);1i[O.6]=1f;w.2d(c(){1I()},0)})}},1G=c(){N(8 i=0;i<W.y;i++){8 x=W[i],6=x.6,b=x.b,2h=x.24&&x.24.2T()==="2S";5(!!6&&2h&&!1i[6]){5(x.R&&x.R.2c){1B(x.R.2c,6,b);1i[6]=1f}K{5((!/^([a-2e-Z:]*\\/\\/)/.2Q(6)&&!1z)||6.U(r.$1,"").1A("/")[0]===w.1P.2X){5(6.1O(0,2)==="//"){6=w.1P.2L+6}V.M({6:6,b:b})}}}}1I()};1G();7.21=1G;7.T=T;c 1F(){18(1f)}5(w.1V){w.1V("2M",1F,1E)}K 5(w.1W){w.1W("39",1F)}})(3b);', 62, 199, '|||||if|href|respond|var|||media|function|body||regex||doc||||return||||req|style|RegExp||docElem|max|min||sheet|length|thisstyle|ss|appendedEls|styleBlocks|thisq|em|rules|head|div|match|fontSize|else|css|push|for|thisRequest|styles|ret|styleSheet||getEmValue|replace|requestQueue|links|mediastyles|only||parseFloat|qs|minw|eachq|ajax|maxw|xmlhttpmethod|gi|applyMedia|width|lastCall|null|eminpx|fakeUsed|ql|true|fullq|in|parsedSheets|keyframes|createElement|new|docElemProp|originalBodyFontSize|px|resizeDefer|hasOwnProperty|removeChild|matchMedia|url|currWidth|now|resizeThrottle|maxnull|minnull|base|split|translate|name|indexOf|false|callMedia|ripCSS|getElementsByTagName|makeRequests|isUnsupportedMediaQuery|eql|useMedia|all|repUrls|substring|location|readyState|other|100|xmlHttp|findStyles|addEventListener|attachEvent|fromResize|hasquery|1em|urls|update|callback|insertBefore|rel|query|cssText|appendChild|lastLink|originalHTMLFontSize|status|mediaQueriesSupported|rawCssText|setTimeout|zA|comments|minmaxwh|isCSS|position|document|matches|link|documentElement|open|XMLHTTP|GET|200|onreadystatechange|XMLHttpRequest|try|catch|Microsoft|ActiveXObject|moz|unsupportedmq|webkit|height|ax|responseText|304|strict|queue|send|absolute|text|type|nextSibling|protocol|resize|parentNode|use|join|test|shift|stylesheet|toLowerCase|lastIndexOf|createTextNode|continue|host|offsetWidth|firstChild||compatMode|clientWidth|size|font|none|background|getTime|clearTimeout|onresize|Date|this|CSS1Compat'.split('|'), 0, {}));

可以从里面发现一些规律,例如一大段字符的split替换、eval(function)的声明。

或者类似如下的大段以单个字母进行的随机命名

!function() {
    var t = document
      , e = 0;
    (window.isLogin || "object" == typeof OP_CONFIG && OP_CONFIG.userInfo && OP_CONFIG.userInfo.uid) && (e = 1);
    var d = 1646064e6
      , o = 16487424e5
      , i = 16461396e5
      , a = 16471872e5
      , c = 0
      , n = null
      , u = 1e4
      , r = "//www.imooc.com"
      , s = "//www.imooc.com/static/moco/v1.0/images/redrain2"
      , l = "20220301";
    location.href.indexOf("guoyuchen") > -1 && (r = "//www-xiongwenhui.imooc.com",
    s = "/static/moco/v1.0/images/redrain2");
    var f = [s + "/ready.png?t=" + l, s + "/go.png?t=" + l, s + "/close-btn.png?t=" + l, s + "/redpacket.png?t=" + l, s + "/boom.png?t=" + l, s + "/result-bg1.png?t=" + l, s + "/use-btn.png?t=" + l, s + "/result-bg2.png?t=" + l, s + "/more-btn.png?t=" + l, s + "/more-btn2.png?t=" + l, s + "/result-bg3.png?t=" + l, s + "/halfAd1.jpeg?t=" + l, s + "/halfAd2.jpeg?t=" + l, s + "/coupon-bg2.png?t=" + l, s + "/coupon-btn2.gif?t=" + l, "//www.imooc.com/static/moco/v1.0/images/march2022/big-ad2.png?t=" + l, "//www.imooc.com/static/moco/v1.0/images/march2022/big-ad2-btn.png?t=" + l]
      , m = {
        modal: '<div class="redRain-modal" id="redRainModal"></div>',
        coupon: '<div class="coupon-wrap center">                    <div class="coupon-btn js-startCoupon"></div>                    <div class="close-btn js-closeCoupon couponCloseBtn618"></div>                </div>',
        halfScreenAd: '<div class="half-wrap center">                            <div class="close-adv imv2-close js-closeHalfScreenAd"></div>                            <a href="//www.imooc.com/act/march2022?utm_source=imooc&utm_campaign=half" target="_blank"><img src="$img" /></a>                        </div>',
        gameStart: '<div id="march2022" class="red-rain">                        <a class="activity-center" data-type="2" target="_blank" style="background-image: url(//www.imooc.com/static/moco/v1.0/images/march2022/big-activity2.png?t=3)">                            <span class="close imv2-add_circle_o js-close-activity"></span>                            <button class="activity-center-btn js-start-game" style="background-image: url(//www.imooc.com/static/moco/v1.0/images/march2022/big-activity2-btn.gif?t=3)"></button>                        </a>                    </div>',
        loading: '<div class="loading center"></div>',
        readyGo: '<div class="readyGo center">                    <img src="' + s + "/ready.png?t=" + l + '" alt="" class="ready">                    <img src="' + s + "/go.png?t=" + l + '" alt="" class="go hide">                </div>',
        gameMain: '<div class="gameMain-wrap">                    <div class="rain-wrap">                        <div class="rain-box js-rain-box"></div>                    </div>                    <div class="line"></div>                    <div class="rainInfo-wrap">                        <div class="clickNum">Combo X <span class="js-rain-clickNum">0</span></div>                        <div class="interval">剩余时间 <span class="js-rain-restTime">15</span>s</div>                    </div>                </div>',
        result1: '<div class="result-wrap1 center">                    <div class="redpacket-price offset">¥$redpacketPrice</div>                    <div class="to-use-btn js-rainToActive offset"></div>                    <p class="tip offset">红包将在 <span class="js-redpacket-lefttime">3天</span> 后失效哦</p>                    <p class="tip offset">下单自动结算,可与优惠券叠加使用</p>                    <div class="close-btn js-closeRedRain"></div>                </div>',
        result2: '<div class="result-wrap2 center">                    <div class="time">$nextTime</div>                    <div class="to-use-btn js-rainToActive"></div>                    <div class="close-btn js-closeRedRain"></div>                </div>',
        result3: '<div class="result-wrap3 center">                    <div class="to-use-btn js-rainToActive"></div>                    <div class="close-btn js-closeRedRain"></div>                </div>',
        rightFloat: '<div id="rightFloat20206666661" class="js-rainToActive">                        <div class="redpacket">                            <div class="redpacketContent">                                $content                            </div>                        </div>                        <div class="bottomTitle"></div>                    </div>',
        rightFloat2: '<div id="rightFloat20206666661" class="js-rainToActive double11"></div>'
    }

反混淆

反混淆的工具是依据混淆原理生成代码,实际需要不断观察分析及调整,比较考验人的耐性

需要具备将问题划分为N个子问题的能力

同时还需要具备js的基础知识,以及还原后如何重构代码(什么工具打包的webpack)

继续深入的话还需要了解JS语法解释器、AST抽象语法树、编程语言实现模式

  • jspacker -> 针对eval
  • unjsa -> 针对JSA
  • crack.js -> 针对javascript-obfuscator
  • jsnice -> 针对UnuglifyJS

本文档针对js解混淆初步入手,如何调试和如何定位进行说明

调试

在网页的调试过程中,需要借助一些工具去“投巧”(
_
)

  • Fiddler/Reres:替换发包和请求内容


image-20220526144505490

  • WT-JS_DEBUG:可以直接调试或美化js代码,同时附带多种解密

    image-20220526144522616

调试

alert调试

联网刚刚起步的时代,网页前端还主要以内容展示为主,浏览器脚本还只能为页面提供非常简单的辅助功能

那个时候,网页主要运行在以IE6为主的浏览器中,JS的调试功能还非常弱,只能通过内置于Window对象中的alert方法来调试

另一方面,alert的调试信息,必须在程序逻辑中添加类似"alert(xxxxx)"这样的语句,才能正常工作,并且alert会阻碍页面的继续渲染

这就意味着开发人员调试完成后,必须手动清除这些调试代码

console调试

新一代的浏览器Firefox、Chrome,包括IE,都相继推出了JS调试控制台,支持使用类似"console.log(xxxx)"的形式,在控制台打印调试信息,而不直接影响页面显示

image-20220519155900045

如果在使用console对象之前先进性存在性验证,其实不删除也不会对业务逻辑造成破坏

为了代码整洁,在调试完成后,还是应尽可能删除这些与业务逻辑无关的调试代码

Chrome开发团队为Chrome浏览器拓展了更丰富的功能,具体操作可以使用Chrome浏览器

断点调试

JS断点调试,即是在浏览器开发者工具中为JS代码添加断点,让JS执行到某一特定位置停住,方便开发者对该处代码段的分析与逻辑处理。为了能够观察到断点调试的效果

给一段代码添加断点的流程是"F12(Ctrl + Shift + I)打开开发工具"——"点击Sources菜单"——"左侧树中找到相应文件"——"点击行号列"即完成在当前行添加/删除断点操作

当断点添加完毕后,刷新页面JS执行到断点位置停住,在Sources界面会看到当前作用域中所有变量和值,只需对每个值进行验证

此处选中第五行,再次刷新页面即将执行到此处

image-20220519160502399

刷新之后的效果

image-20220519160715970

可以发现右侧有这样一行工具栏

image-20220519160739806

工具栏从左到右各图标的功能分别如下:

Pause/Resume script execution:F8 暂停/恢复脚本执行(程序执行到下一断点停止)

Step over next function call: F10 执行到下一步的函数调用(跳到下一行)

Step into next function call: F11 进入当前函数

Step out of current function:Shift+F11 跳出当前执行函数

Step: F9 同F11,将跨国异步函数进入下一行

Deactive/Active all breakpoints:Ctrl+F8 关闭/开启所有断点(不会取消)

Pause on exceptions:异常情况自动断点设置

Debugger断点

在开发中偶尔会遇到异步加载html片段(包含内嵌JS代码)的情况,而这部分JS代码在Sources树种无法找到

因此无法直接在开发工具中直接添加断点,那么如果想给异步加载的脚本添加断点,此时"debugger;"就发挥作用了

通过在代码中添加"debugger;"语句,当代码执行到该语句的时候就会自动断点

接下去的操作就跟在Sources面板添加断点调试几乎一模一样,唯一的区别在于调试完后需要删除该语句

DOM断点调试

在DOM元素上添加断点,进而达到调试的目的

代码展开

如果页面源代码显示单行,可以点击左下角的大括号展开,更为直观的浏览代码

image-20220519163056019

结果如下图

image-20220519163118540

搜索关键字

在页面内通过ctrl+F,可以出现搜索框

image-20220519163158080

反调试

一些网站会通过监控网页窗口的长宽高以此监视是否开启调试模式以此来进行反调试,对此需要将devtool独立出来

image-20220523161109942

具体请点击右上角的三个点,选择第一行的左边第一个按键,即可将调试窗口独立

image-20220523161100341

实验

普通解混淆

以某东的登录为例子

image-20220523175721059

随便输入数据找到post

image-20220523175637616

看一下payload这里可以发现密码nloginpwd是经过加密的,还有一个pubKey和sa_token。那么需要解决的加密方式就是这三个

image-20220523175711706

全局搜索nloginpwd,判断一下可能的位置,定断点刷新一下,定过来了

image-20220523175817583

可以看到这里有个data,console打出来看看能和我们抓到的Post匹配上,可以发现这里的pubKey和sa_token是写死的

image-20220523175841152

可以看到这里对于nloginpwd有个getEntryPwd函数,应该是对此进行了加密,跟进去看一下。这里可以打印一下getEntryPwd的赋值,可以发现是我们输入的原密码

image-20220523180022647

image-20220523180142991

根据名字看一下,这里赋值pubKey,同时进行一个JSEncrypt的操作,跟进去看一下

可以发现这段特别长,直接将整段copy出来尝试运行

image-20220523180419152

可以发现这个代码是经过加密的,首行直接说明了。这种情况建议copy下来通过直接WT-JS_DEBUG尝试运行

image-20220523180622563

代码copy过来发现有一些变量没有赋值,这里在首行直接赋空值,保证代码顺利运行就行

image-20220526103318093

赋值之后重新运行可以看到加载成功

image-20220526103345375

回溯源码理一下整个加密流程,将其串起来结合copy的加密算法写个解密过程。发现和抓到的密码是不一样的,而且每次运行结果都不一样,怀疑该加密跟时间有关

image-20220526104621935

在某东的登录再用同样的密码登录几次尝试,发现每次加密后的密码也不一样

image-20220526104654874

image-20220526104705667

反调试解混淆

一些网站可能通过监视屏幕的宽高比,判断是否开启开发者工具而禁止调试,或者直接禁用F12。

这种情况以
PM2.5实时查询|PM2.5历史数据查询|PM2.5全国城市排名|PM2.5雾霾地图|中国空气质量在线监测分析平台|真气网 (aqistudy.cn)
为例举例说明如何反调试跳出debugger()函数

image-20220526110805867

尝试F12开启调试窗口,直接被弹窗禁止了。这样就需要手动通过更多工具-开发者人员工具调出调试台。同时将其分离成独立窗口

image-20220526110818945

可以看见因为开始调试直接进入debugger反调试

image-20220526110955108

可以发现这个网页因为反调试网络啥信息都没有了(
Φ皿Φ
),这个时候可以通过窗口旁边的调用堆栈看看这个debugger是从哪里弹出来的

image-20220526666666759296

发现是一个txsdefwsw和c,进去看一下

image-20220526666666950633

追溯c的源码过去看看没有发现什么有用的信息,感觉程序栈不完整没有捕获到相关函数。重新刷新页面一下,发现新内容

image-20220526131827671

(
_
)发现了反调试的代码,发现首页的源码。这里还包含一些其他反调试的检测。那么就是在这里触发的debug。首页当检测到非法调试之后,触发txsdefwsw()函数,全局搜索一下这个函数。

image-20220526131851285

发现这里是通过eval()函数去执行一个function,(eval函数可以执行表达式,具体深入可以自行google)根据注释可以发现这里有一个debug的检测,那么看一下这两个eval里的function是什么。

image-20220526132427283

控制台通过var打印一下这两个function的返回值,可以发现两个eval分别执行endebug和txsdefwsw两个函数。

image-20220526133402582

image-20220526143514050

image-20220526143557326

思路就是通过替换这个eval函数的执行函数,让他执行一个空值的函数从而跳过这里的debug函数。可以用工具reres去替换这个js链接为本地经过改写的js文件。这样网页执行时,调用同名的空函数则不会触发debug。

image-20220526143611477

image-20220526143626478

可以发现替换后源码的js变成了这样

image-20220527140449510

再次刷新还是debug,(╯▔皿▔)╯跟过去发现这里还有一层反调试

image-20220527142212473

console打印一下,发现这里再次调用了首页的检测逻辑

image-20220527142357030

但这里经过多次刷新后发现,此处的eval()表达式执行的函数名是随机变换的。因此前步涉及到的直接替换函数在这里就不起作用了。这个时候就需要跟进函数,发现这个随即名称的函数是针对随机输入的固定base64加密,最后输出debug函数

那么此处只要单步调试,打断点定在程序执行加密前,将输入赋空值,这样输出必定为空,则可以绕过debug

其他

从慕课网的源码里也发现了一些信息,诸如内网ip或者一些网站设置的一些弱密码。(此类信息一般很好分析,大多源码里会被加上注释╮(╯▽╰)╭)

image-20220526144753813

同时JS混淆还包括多种加密,加密方法需要视具体源码所定,其加密可能是传统加密亦或者编写者进行过一些调整。这些都需要调试者去尝试判断

具体分析依旧回归源码,诸如实验一的某东JSencode加密

防护

以IPS特征库登录界面登录逻辑为例

image-20220527113411921

首先看一下一个成功登录的流量

image-20220602091141109

再看一下失败登录的流量,可以发现没有调试的情况下流量不会向相关js文件发送请求

image-20220527164925675

走正常的登录流程,可以发现,只是向登录接口发送了一个post请求

image-20220602091237436

分析payload发现发送Username和Password,本例没有加密(常规会对此加密),为作说明同样全局搜索Password,寻找可疑处打断点

image-20220527115866666669

当给源码打上断点后,再次刷新登录,可以发现向断点处接口jquery.min.map也发送了请求。根据源码注释也也可以发现,我们打断点的位置正好对应了请求的url

image-20220527113714521

image-20220602091334186

防护

针对此类调试情况,可以根据流量是否向项目结构内js网页发送请求判断是否正在js调试。此类防护IPS规则不好写受用户自定义的命名限制,建议自定义添加规则,针对隐私/重要的js的url地址提取content进行JS调试的防护

结合上文提到的反调试,即跳出eval赋值表达式函数。替换网页js源码。

防护

针对反调试函数赋空可以总结eval(function xxx{})此类格式,发现替换js页面请求里会没有这个url

尝试替换ace-extra.min.js

替换页面,没有该url

image-20220602091421892

无替换,出现

image-20220602091458230

尝试通过浏览器的覆盖功能,可以出现修改的请求,但流量内依旧没有改写后的字符

image-20220527145645472

image-20220527145655393

因此,对于替换类的解混淆,无法提取规则。需要依据上文提到的特殊url访问去进行js解混淆的防护

但在调试时,response会返回经过混淆的代码。可以根据混淆代码的一些固定格式去提取特征,进行防护

总结

JS混淆一般被用于反爬虫和信息保护,不过只要善用工具和足够的耐心,结合一些工具就可以从源码里收获很多的有用信息

对于攻击者进行js调试时,网站的代码需要考虑从如何反调试的角度去思考

作为js解混淆的使用者出发,需要了解和积累解混淆的经验,才能应对更多的反爬虫,收集到更多的爬虫信息

NL2SQL进阶系列(1):DB-GPT-Hub、SQLcoder、Text2SQL开源应用实践详解

NL2SQL基础系列(1):业界顶尖排行榜、权威测评数据集及LLM大模型(Spider vs BIRD)全面对比优劣分析[Text2SQL、Text2DSL]

NL2SQL基础系列(2):主流大模型与微调方法精选集,Text2SQL经典算法技术回顾七年发展脉络梳理

1. MindSQL(库)

MindSQL 是一个 Python RAG(检索增强生成)库,旨在仅使用几行代码来简化用户与其数据库之间的交互。 MindSQL 与 PostgreSQL、MySQL、SQLite 等知名数据库无缝集成,还通过扩展核心类,将其功能扩展到 Snowflake、BigQuery 等主流数据库。 该库利用 GPT-4、Llama 2、Google Gemini 等大型语言模型 (LLM),并支持 ChromaDB 和 Fais 等知识库。

官方链接:
https://pypi.org/project/mindsql/

https://github.com/Mindinventory/MindSQL

  • 使用案例
#!pip install mindsql

from mindsql.core import MindSQLCore
from mindsql.databases import Sqlite
from mindsql.llms import GoogleGenAi
from mindsql.vectorstores import ChromaDB

#Add Your Configurations
config = {"api_key": "YOUR-API-KEY"}

#Choose the Vector Store. LLM and DB You Want to Work With And
#Create MindSQLCore Instance With Configured Llm, Vectorstore, And Database
minds = MindSQLCore(
    llm=GoogleGenAi(config=config),
    vectorstore=ChromaDB(),
    database=Sqlite()
)

#Create a Database Connection Using The Specified URL
connection = minds.database.create_connection(url="YOUR_DATABASE_CONNECTION_URL")

#Index All Data Definition Language (DDL) Statements in The Specified Database Into The Vectorstore
minds.index_all_ddls(connection=connection, db_name='NAME_OF_THE_DB')

#Index Question-Sql Pair in Bulk From the Specified Example Path
minds.index(bulk=True, path="your-qsn-sql-example.json")

#Ask a Question to The Database And Visualize The Result
response = minds.ask_db(
    question="YOUR_QUESTION",
    connection=connection,
    visualize=True
)

#Extract And Display The Chart From The Response
chart = response["chart"]
chart.show()

#Close The Connection to Your DB
connection.close()

2.DB-GPT-Hub:利用LLMs实现Text-to-SQL微调

DB-GPT-Hub是一个利用LLMs实现Text-to-SQL解析的实验项目,主要包含数据集收集、数据预处理、模型选择与构建和微调权重等步骤,通过这一系列的处理可以在提高Text-to-SQL能力的同时降低模型训练成本,让更多的开发者参与到Text-to-SQL的准确度提升工作当中,最终实现基于数据库的自动问答能力,让用户可以通过自然语言描述完成复杂数据库的查询操作等工作。

2.1、数据集

本项目案例数据主要以
Spider
数据集为示例 :

  • Spider
    : 一个跨域的复杂text2sql数据集,包含了10,181条自然语言问句、分布在200个独立数据库中的5,693条SQL,内容覆盖了138个不同的领域。
    下载链接

其他数据集:

  • WikiSQL:
    一个大型的语义解析数据集,由80,654个自然语句表述和24,241张表格的sql标注构成。WikiSQL中每一个问句的查询范围仅限于同一张表,不包含排序、分组、子查询等复杂操作。
  • CHASE
    : 一个跨领域多轮交互text2sql中文数据集,包含5459个多轮问题组成的列表,一共17940个<query, SQL>二元组,涉及280个不同领域的数据库。
  • BIRD-SQL:
    数据集是一个英文的大规模跨领域文本到SQL基准测试,特别关注大型数据库内容。该数据集包含12,751对文本到SQL数据对和95个数据库,总大小为33.4GB,跨越37个职业领域。BIRD-SQL数据集通过探索三个额外的挑战,即处理大规模和混乱的数据库值、外部知识推理和优化SQL执行效率,缩小了文本到SQL研究与实际应用之间的差距。
  • CoSQL:
    是一个用于构建跨域对话文本到sql系统的语料库。它是Spider和SParC任务的对话版本。CoSQL由30k+回合和10k+带注释的SQL查询组成,这些查询来自Wizard-of-Oz的3k个对话集合,查询了跨越138个领域的200个复杂数据库。每个对话都模拟了一个真实的DB查询场景,其中一个工作人员作为用户探索数据库,一个SQL专家使用SQL检索答案,澄清模棱两可的问题,或者以其他方式通知。
  • 按照
    NSQL
    的处理模板,对数据集做简单处理,共得到约
    20w条训练数据

2.2、基座模型

DB-GPT-HUB目前已经支持的base模型有:













模型可以基于quantization_bit为4的量化微调(QLoRA)所需的最低硬件资源,可以参考如下:

模型参数 GPU RAM CPU RAM DISK
7b 6GB 3.6GB 36.4GB
13b 13.4GB 5.9GB 60.2GB

其中相关参数均设置的为最小,batch_size为1,max_length为512。根据经验,如果计算资源足够,为了效果更好,建议相关长度值设置为1024或者2048。

2.3 快速使用

  • 环境安装
git clone https://github.com/eosphoros-ai/DB-GPT-Hub.git
cd DB-GPT-Hub
conda create -n dbgpt_hub python=3.10 
conda activate dbgpt_hub
pip install poetry
poetry install

2.3.1 数据预处理

DB-GPT-Hub使用的是信息匹配生成法进行数据准备,即结合表信息的 SQL + Repository 生成方式,这种方式结合了数据表信息,能够更好地理解数据表的结构和关系,适用于生成符合需求的 SQL 语句。

spider数据集链接
下载spider数据集,默认将数据下载解压后,放在目录dbgpt_hub/data下面,即路径为
dbgpt_hub/data/spider

数据预处理部分,
只需运行如下脚本
即可:

##生成train数据 和dev(eval)数据,
poetry run sh dbgpt_hub/scripts/gen_train_eval_data.sh


dbgpt_hub/data/
目录你会得到新生成的训练文件example_text2sql_train.json 和测试文件example_text2sql_dev.json ,数据量分别为8659和1034条。 对于后面微调时的数据使用在dbgpt_hub/data/dataset_info.json中将参数
file_name
值给为训练集的文件名,如example_text2sql_train.json。

生成的json中的数据形如:

    {
        "db_id": "department_management",
        "instruction": "I want you to act as a SQL terminal in front of an example database, you need only to return the sql command to me.Below is an instruction that describes a task, Write a response that appropriately completes the request.\n\"\n##Instruction:\ndepartment_management contains tables such as department, head, management. Table department has columns such as Department_ID, Name, Creation, Ranking, Budget_in_Billions, Num_Employees. Department_ID is the primary key.\nTable head has columns such as head_ID, name, born_state, age. head_ID is the primary key.\nTable management has columns such as department_ID, head_ID, temporary_acting. department_ID is the primary key.\nThe head_ID of management is the foreign key of head_ID of head.\nThe department_ID of management is the foreign key of Department_ID of department.\n\n",
        "input": "###Input:\nHow many heads of the departments are older than 56 ?\n\n###Response:",
        "output": "SELECT count(*) FROM head WHERE age  >  56",
        "history": []
    }, 

项目的数据处理代码中已经嵌套了
chase

cosql

sparc
的数据处理,可以根据上面链接将数据集下载到data路径后,在
dbgpt_hub/configs/config.py
中将
SQL_DATA_INFO
中对应的代码注释松开即可。

2.3.2 快速开始

首先,用如下命令安装
dbgpt-hub

pip install dbgpt-hub

然后,指定参数并用几行代码完成整个Text2SQL fine-tune流程:

from dbgpt_hub.data_process import preprocess_sft_data
from dbgpt_hub.train import start_sft
from dbgpt_hub.predict import start_predict
from dbgpt_hub.eval import start_evaluate

#配置训练和验证集路径和参数
data_folder = "dbgpt_hub/data"
data_info = [
        {
            "data_source": "spider",
            "train_file": ["train_spider.json", "train_others.json"],
            "dev_file": ["dev.json"],
            "tables_file": "tables.json",
            "db_id_name": "db_id",
            "is_multiple_turn": False,
            "train_output": "spider_train.json",
            "dev_output": "spider_dev.json",
        }
]

#配置fine-tune参数
train_args = {
            "model_name_or_path": "codellama/CodeLlama-13b-Instruct-hf",
            "do_train": True,
            "dataset": "example_text2sql_train",
            "max_source_length": 2048,
            "max_target_length": 512,
            "finetuning_type": "lora",
            "lora_target": "q_proj,v_proj",
            "template": "llama2",
            "lora_rank": 64,
            "lora_alpha": 32,
            "output_dir": "dbgpt_hub/output/adapter/CodeLlama-13b-sql-lora",
            "overwrite_cache": True,
            "overwrite_output_dir": True,
            "per_device_train_batch_size": 1,
            "gradient_accumulation_steps": 16,
            "lr_scheduler_type": "cosine_with_restarts",
            "logging_steps": 50,
            "save_steps": 2000,
            "learning_rate": 2e-4,
            "num_train_epochs": 8,
            "plot_loss": True,
            "bf16": True,
}

#配置预测参数
predict_args = {
            "model_name_or_path": "codellama/CodeLlama-13b-Instruct-hf",
            "template": "llama2",
            "finetuning_type": "lora",
            "checkpoint_dir": "dbgpt_hub/output/adapter/CodeLlama-13b-sql-lora",
            "predict_file_path": "dbgpt_hub/data/eval_data/dev_sql.json",
            "predict_out_dir": "dbgpt_hub/output/",
            "predicted_out_filename": "pred_sql.sql",
}

#配置评估参数
evaluate_args =  {
            "input": "./dbgpt_hub/output/pred/pred_sql_dev_skeleton.sql",
            "gold": "./dbgpt_hub/data/eval_data/gold.txt",
            "gold_natsql": "./dbgpt_hub/data/eval_data/gold_natsql2sql.txt",
            "db": "./dbgpt_hub/data/spider/database",
            "table": "./dbgpt_hub/data/eval_data/tables.json",
            "table_natsql": "./dbgpt_hub/data/eval_data/tables_for_natsql2sql.json",
            "etype": "exec",
            "plug_value": True,
            "keep_distict": False,
            "progress_bar_for_each_datapoint": False,
            "natsql": False,
}

#执行整个Fine-tune流程
preprocess_sft_data(
      data_folder = data_folder,
      data_info = data_info
)

start_sft(train_args)
start_predict(predict_args)
start_evaluate(evaluate_args)

2.3.3、模型微调

本项目微调不仅能支持QLoRA和LoRA法,还支持deepseed。 可以运行以下命令来微调模型,默认带着参数
--quantization_bit
为QLoRA的微调方式,如果想要转换为lora的微调,只需在脚本中去掉quantization_bit参数即可。
默认QLoRA微调,运行命令:

poetry run sh dbgpt_hub/scripts/train_sft.sh

微调后的模型权重会默认保存到adapter文件夹下面,即dbgpt_hub/output/adapter目录中。
如果使用多卡训练,想要用deepseed
,则将train_sft.sh中默认的内容进行更改,
调整为:

CUDA_VISIBLE_DEVICES=0 python dbgpt_hub/train/sft_train.py \
    --quantization_bit 4 \
    ...

更改为:

deepspeed --num_gpus 2  dbgpt_hub/train/sft_train.py \
    --deepspeed dbgpt_hub/configs/ds_config.json \
    --quantization_bit 4 \
    ...

如果需要指定对应的显卡id而不是默认的前两个如3,4,可以如下

deepspeed --include localhost:3,4  dbgpt_hub/train/sft_train.py \
    --deepspeed dbgpt_hub/configs/ds_config.json \
    --quantization_bit 4 \
    ...

其他省略(...)的部分均保持一致即可。 如果想要更改默认的deepseed配置,进入
dbgpt_hub/configs
目录,在ds_config.json 更改即可,默认为stage2的策略。

脚本中微调时不同模型对应的关键参数lora_target 和 template,如下表:

模型名 lora_target template
LLaMA-2 q_proj,v_proj llama2
CodeLlama-2 q_proj,v_proj llama2
Baichuan2 W_pack baichuan2
Qwen c_attn chatml
sqlcoder-7b q_proj,v_proj mistral
sqlcoder2-15b c_attn default
InternLM q_proj,v_proj intern
XVERSE q_proj,v_proj xverse
ChatGLM2 query_key_value chatglm2
LLaMA q_proj,v_proj -
BLOOM query_key_value -
BLOOMZ query_key_value -
Baichuan W_pack baichuan
Falcon query_key_value -

train_sft.sh
中其他关键参数含义:

quantization_bit:是否量化,取值为[4或者8]
model_name_or_path: LLM模型的路径
dataset: 取值为训练数据集的配置名字,对应在dbgpt_hub/data/dataset_info.json 中外层key值,如example_text2sql。
max_source_length: 输入模型的文本长度,如果计算资源支持,可以尽能设大,如1024或者2048。
max_target_length: 输出模型的sql内容长度,设置为512一般足够。
output_dir : SFT微调时Peft模块输出的路径,默认设置在dbgpt_hub/output/adapter/路径下 。
per_device_train_batch_size : batch的大小,如果计算资源支持,可以设置为更大,默认为1。
gradient_accumulation_steps : 梯度更新的累计steps值
save_steps : 模型保存的ckpt的steps大小值,默认可以设置为100。
num_train_epochs : 训练数据的epoch数

2.3.4、模型预测

项目目录下
./dbgpt_hub/
下的
output/pred/
,此文件路径为关于模型预测结果默认输出的位置(如果没有则建上)。
预测运行命令:

poetry run sh ./dbgpt_hub/scripts/predict_sft.sh

脚本中默认带着参数
--quantization_bit
为QLoRA的预测,去掉即为LoRA的预测方式。
其中参数
predicted_input_filename
为要预测的数据集文件,
--predicted_out_filename
的值为模型预测的结果文件名。默认结果保存在
dbgpt_hub/output/pred
目录。

2.3.5、模型权重

可以从Huggingface查看社区上传的第二版Peft模块权重
huggingface地址
(202310) ,在spider评估集上的执行准确率达到0.789。

  • 模型和微调权重合并
    如果你需要将训练的基础模型和微调的Peft模块的权重合并,导出一个完整的模型。则运行如下模型导出脚本:
poetry run sh ./dbgpt_hub/scripts/export_merge.sh

注意将脚本中的相关参数路径值替换为你项目所对应的路径。

2.3.6、模型评估

对于模型在数据集上的效果评估,默认为在
spider
数据集上。
运行以下命令来:

poetry run python dbgpt_hub/eval/evaluation.py --plug_value --input  Your_model_pred_file

你可以在
这里
找到最新的评估和实验结果。
注意
: 默认的代码中指向的数据库为从
Spider官方网站
下载的大小为95M的database,如果你需要使用基于Spider的
test-suite
中的数据库(大小1.27G),请先下载链接中的数据库到自定义目录,并在上述评估命令中增加参数和值,形如
--db Your_download_db_path

2.4 小结

整个过程会分为三个阶段:

  • 阶段一:


    • 搭建基本框架,基于数个大模型打通从数据处理、模型SFT训练、预测输出和评估的整个流程
      现在支持












  • 阶段二:





  • 阶段三:




3.sqlcoder

官方链接:
https://github.com/defog-ai/sqlcoder

Defog组织提出的先进的Text-to-SQL的大模型,表现亮眼,效果优于GPT3.5、wizardcoder和starcoder等,仅次于GPT4。

将每个生成的问题分为6类。该表显示了每个模型正确回答问题的百分比,并按类别进行了细分。

4.modal_finetune_sql

项目基于LLaMa 2 7b模型进行Text-to-SQL微调,有完整的训练、微调、评估流程。

链接:
https://github.com/run-llama/modal_finetune_sql

5.LLaMA-Efficient-Tuning

这是一个易于使用的LLM微调框架,支持LLaMA-2、BLOOM、Falcon、Baichuan、Qwen、ChatGLM2等。

链接:
https://github.com/hiyouga/LLaMA-Factory/tree/main

  • 多种模型
    :LLaMA、Mistral、Mixtral-MoE、Qwen、Yi、Gemma、Baichuan、ChatGLM、Phi 等等。
  • 集成方法
    :(增量)预训练、指令监督微调、奖励模型训练、PPO 训练、DPO 训练和 ORPO 训练。
  • 多种精度
    :32 比特全参数微调、16 比特冻结微调、16 比特 LoRA 微调和基于 AQLM/AWQ/GPTQ/LLM.int8 的 2/4/8 比特 QLoRA 微调。
  • 先进算法
    :GaLore、DoRA、LongLoRA、LLaMA Pro、LoRA+、LoftQ 和 Agent 微调。
  • 实用技巧
    :FlashAttention-2、Unsloth、RoPE scaling、NEFTune 和 rsLoRA。
  • 实验监控
    :LlamaBoard、TensorBoard、Wandb、MLflow 等等。
  • 极速推理
    :基于 vLLM 的 OpenAI 风格 API、浏览器界面和命令行接口。
  • 训练方法
方法 全参数训练 部分参数训练 LoRA QLoRA
预训练
指令监督微调
奖励模型训练
PPO 训练
DPO 训练
ORPO 训练
  • 可视化使用教学

https://github.com/hiyouga/LLaMA-Factory/assets/16256802/ec36a9dd-37f4-4f72-81bd-d76c6d0a6594

更多优质内容请关注公号:汀丶人工智能;会提供一些相关的资源和优质文章,免费获取阅读。