跳到主要内容

internxt网盘分享文件提取外链下载并解密

· 阅读需 6 分钟
RibomBalt
CTF enthusiastist, GeoPhysics PhD, Amateur coder

TL;DR

  • internxt网盘分享文件的在线下载没有断点续传功能,因此下载大文件时造成困扰。
  • internxt网盘下载分享文件时,是从单一bucket中下载aes-256-ctr加密后的大文件,同时流式解密存储
  • PyCryptodome和nodejs crypto库对于AES-256-CTR加密的实现不同,本文提出了与Nodejs加密等效的Python代码

背景

Internxt是一个外网提供云存储服务的公司,类似百度网盘等。用户可以上传分享文件,免费用户只能用1GB。中国大陆地区需要用特殊手段才能访问。

Internxt网盘有网页端和客户端,客户端类似onedrive集成在Windows文件管理器里。然而对于外链分享功能,客户端并不支持直接下载分享的文件,必须要从网页端进行下载。

Internxt网盘的网页端下载不限制下载大小,但是没有类似MEGA网盘那样方便的下载进度管理功能,只要点击下载按钮后就不能暂停,中断了也不能恢复,这导致下载过程中一旦因为网络原因中断,就必须要重新下载,这对于下载大文件(特别是在我的需求中,需要下载20GB大小的单个文件)几乎是不可用的。

好在和国内云存储服务商不同,Internxt网盘的分享文件下载是可以通过抓包获取到下载链接的。本文将介绍如何提取Internxt网盘分享文件的下载链接,并使用Python脚本进行下载和解密。

Internxt网盘下载网页逆向

打开F12开发者工具,点击网页中下载按钮,在网络请求中可以看到一条单一请求,其流量高速匀速增长,推定为真实数据的下载请求。

https://temporal-uploads-bucket.s3.gra.io.cloud.ovh.net/a4c0bb5f-f3d8-4bb1-bc7a-a99
0a75619d7?AWSAccessKeyId=4711a0ec28474c53ac70813a30919da5&Expires=1752321797&Signature=B4%2BpowLIOmgVzHuIr%2FCyv
7fx70k%3D

从域名信息可以判断,这是一个存储在OVH云上的S3兼容存储桶,可以使用wget工具进行高速下载,在我的网络环境中,可以达到5~6MB/s的速度。然而,下载的文件是加密的,这体现在尽管文件扩展名为zip,但文件头并非504B0304的zip文件头,而是一个随机的字节序列,因此需要寻找网页JS配套的解密逻辑。

查看开发者工具中,对应数据网络请求的调用栈,可以发现这个网页的前端是nodejs+webpack编译而成,相对可读性较强。最终在NetworkFacade.ts文件中,可以发现关键函数async download中的函数调用await downloadFile,包含两个关键的回调函数,分别对应fetchgetDecryptedStream解密。后者底层调用的是nodejs的crypto库,其功能可以直接根据已有代码猜出:

注意:这里两个回调函数的调用顺序并非是要等其中一个执行完毕才执行另一个,而是可以异步的。

  async download(
bucketId: string,
fileId: string,
mnemonic: string,
options?: DownloadOptions,
): Promise<ReadableStream> {
const encryptedContentStreams: ReadableStream<Uint8Array>[] = [];
let fileStream: ReadableStream<Uint8Array>;

// TODO: Check hash when downloaded

await downloadFile(
fileId,
bucketId,
mnemonic,
this.network,
this.cryptoLib,
Buffer.from,
async (downloadables) => {
for (const downloadable of downloadables) {
if (options?.abortController?.signal.aborted) {
throw new Error('Download aborted');
}

const encryptedContentStream = await fetch(downloadable.url, {
signal: options?.abortController?.signal,
}).then((res) => {
if (!res.body) {
throw new Error('No content received');
}

return res.body;
});

encryptedContentStreams.push(encryptedContentStream);
}
},
async (algorithm, key, iv, fileSize) => {
const decryptedStream = getDecryptedStream(
encryptedContentStreams,
createDecipheriv('aes-256-ctr', options?.key || (key as Buffer), iv as Buffer),
);

fileStream = buildProgressStream(decryptedStream, (readBytes) => {
options && options.downloadingCallback && options.downloadingCallback(fileSize, readBytes);
});
},
(options?.token && { token: options.token }) || undefined,
);

// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
return fileStream!;
}

很明显是AES-256-CTR解密,需要Key和IV。通过打断点的方式,容易知道Key和IV的值(Key应该取options.key)。这里Key和IV都是Uint8Array类型,可以用如下代码转为hex。

Array.from(options.key).map(c=>c.toString(16)).map(c=>c.length<2?'0'+c:c).join('')

值得注意,Key是32Bytes,而IV是16Bytes,这和我之前熟悉的CBC等模式,需要IV和块等长的特性不同。

在进入下一步之前,我们可以先用cyberchef验证密文用这组Key+IV解密后,确实为Zip文件头。

Nodejs标准库-PyCryptodome实现不同

AES-256-CTR本质

AES-CTR

CTR模式的加密过程是将明文分成多个块,每个块与一个计数器(Counter)进行异或操作。

计数器的处理方式可能有多种,有些情况下可能会给固定前缀,然后后半部分用自增的方式生成与块长度相等的计数器。有些情况下,则会以整个前缀作为计数器的初始值。一般情况下,前缀会被称为nonce。CTR模式的好处在于后一个块的加密不依赖于前一个块的结果,因此可以并行处理。

两种调用接口

在Nodejs中,crypto库的createDecipheriv函数可以直接使用CTR模式进行解密。PyCryptodome库也有类似的接口,但两者的实现方式略有不同。

对于Nodejs,aes-256-ctr模式固定使用32Bytes的Key和16Bytes的IV,IV是作为整个计数器的初始值整体处理。

而对于PyCryptodome库,AES.new函数的CTR模式不会接受IV参数,但会有多种处理方式。

  • AES.new(key, AES.MODE_CTR, nonce=iv):使用iv作为nonce,但nonce最长只能8Bytes。
  • AES.new(key, AES.MODE_CTR, initial_value=iv):使用initial_value作为计数器的初始值,但是initial_value作为整数,不能大于127位(我也不知道为什么)
  • 直接传入counter对象,counter对象可以指定nbits,little_endianinitial_value等参数,更加自由。

等效Python代码

经过试错,发现与nodejs真正的等效代码为:

from Crypto.Cipher import AES
from Crypto.Util import Counter
from tqdm import tqdm
import os

ENC_FILE = ...
DEC_FILE = ...
# NOTE difference between pycryptodome and nodejs crypto:
# This should be nonce, which is less than 16bytes, so it must be big-endian
iv = '0ab8eaea6d99d91b34b4a2e1b14f0e54'
key = 'b92c0b88356d14a7dbf1e80a929f7264784ec47f9e3ac9c48c5de9ed77ab2aa4'

counter = Counter.new(
nbits=128,
little_endian=False,
initial_value=int.from_bytes(bytes.fromhex(iv), 'big'),
)
cipher = AES.new(
key=bytes.fromhex(key),
mode=AES.MODE_CTR,
counter=counter
)
total_size = os.path.getsize(ENC_FILE)

pbar = tqdm(total=total_size, unit='B', unit_scale=True, desc='Decrypting')
with open(ENC_FILE, 'rb') as f:
with open(DEC_FILE, 'wb') as out:
try:
while True:
chunk = f.read(1024 * 1024)
if not chunk:
break
decrypted_chunk = cipher.decrypt(chunk)
out.write(decrypted_chunk)
pbar.update(len(chunk))
except KeyboardInterrupt:
print("\nDecryption interrupted by user.")
except EOFError:
print("\nReached end of file.")
pbar.close()

注意Counter部分必须传入Big Endian。如果两个参数都改为Little Endian,解码不会报错,但结果从第二个16Bytes开始会出现错误,较为隐蔽。好在ZIP文件开头有大量明文,容易发现发现排除错误。