部分日本漫画站点图像混淆算法逆向与解析

标准流程

  1. 抓包,检查API通信格式、图像采用怎样的加密/混淆方式,有时候可以直接从抓到的响应中找到其加密/混淆方式的指示信息。
  2. 检查图像对应的元素是以怎样的形式呈现图像的,是canvas?还是img但是用的blob:
    • 如果图像请求有栈跟踪,就在浏览器开发者工具中查看栈跟踪,找到对应的解密流程即可。
    • 如果没有,就给图像元素的父元素打断点,找到其渲染逻辑。
    • 如果都难以找到其解密流程,考虑从API请求开始梳理

如果提供了非网页套壳的手机客户端,也可以尝试抓包,有的公司的手机客户端是无打乱的。

コミチ+ (Comici+)

  • 固定分块数 图像打乱
  • JPEG格式

使用Comici+ SaaS搭建的平台均采用统一的加密方式,只是由于部署的版本不同,在API上可能有些出入。

抓包观察

可以看到在/api/book/contentsInfo有下面这样的响应

{
	"totalPages": 23,
	"scrollDirection": "横",
	"spreadDesignation": 2,
	"result": [{
		"imageUrl": "",
		"scramble": "[4, 5, 1, 10, 14, 3, 2, 7, 0, 6, 15, 9, 11, 8, 12, 13]",
		"sort": 0,
		"width": 1260,
		"height": 1780,
		"expiresOn": 1770183109000
	}]
}

这个scramble属于是一看就知道肯定是图像块的打乱顺序了,所以接下来只要找到这个顺序到底是怎么对应的就行了。
检查图像元素,发现是canvas形式,且图像请求没有栈跟踪,所以选择对canvas的父节点打断点。

追踪解密流程

一触发断点,马上就看到了断点位置下面的代码段

for (
  var u = Math.floor(r / 4),
  d = Math.floor(i / 4),
  f = 0,
  v = function (e, t) {
    for (
      var n = e.length,
      r = [],
      i = t.replace(/\s+/g, '').slice(1).slice(0, - 1).split(','),
      o = 0;
      o < n;
      o++
    ) r.push(e[i[o]]);
    return r
  }(Ir, n.scramble),
  p = 0;
  p < 4;
  p++
) for (var h = 0; h < 4; h++) {
  var g = v[f][0],
  m = v[f][1];
  l.drawImage(e, u * g, d * m, u, d, u * p, d * h, u, d),
  f++
}

梳理一下就是下面这样

pos = list()
for col in range(4):
    for row in range(4):
        pos.append((row, col))

scrambled_pos = [pos[i] for i in scramble]

SpeedBinB

  1. ヤンマガWeb (Young Manga)
  2. きら星ポータル (Kirapo)
  3. がうがうモンスター+ (GauGau Monster+)
  • 动态分块数 非精确切片 图像打乱
  • 复杂分块(可选)
  • JPEG格式

SpeedBinb也是被多个网站采用的常用加密方式,以 ヤンマガWeb (Young Manga)きら星ポータル (Kirapo) 为其两大类加密方式的代表

  1. 解密ptbl/ctbl,对ptblctbl密钥表进行派生,得到混淆类型和位置信息,按类型解混淆 ヤンマガWeb (Young Manga)
  2. 直接通过API下发ptimg.json,提供一个甚至人类可读的分块映射表 きら星ポータル (Kirapo)

只要在资源列表中看到带有speedbinb字样的JS文件,即可判断该网站采用SpeedBinb加密。
特色的API接口为bibGetCntnt...,非常有标志性

因而我们应当直接对SpeedBinb本体JS下手,从中逆向出其解密流程即可。

这玩意真的很烦人,直接丢给AI给你解混淆比我们在这分析半天快得多。

GigaViewer

  1. 一迅プラス (Ichijin Plus / IchiComi)
  2. コミック アース・スター (COMIC EARTH STAR)
  3. くらげバンチ (Kurage Bunch)
  4. HERO'S Web(コミプレ)
  • 固定分块数 图像打乱
  • JPEG格式

下面以 一迅プラス (Ichijin Plus / IchiComi) 为例,介绍一下我的逆向流程。

抓包观察

没有找到明显的数组和哈希值等信息,考虑固定顺序或内嵌顺序。

追踪解密流程

其图像请求有栈跟踪,直接从栈跟踪入手。
首先注意到一个名称十分引人注目:skipDrm,在脚本里搜索一下:

i.skipDrm
  ? this.appendPageImage(e, t, t.src)
  : requestIdleCallback(
      () => {
        const i = (0, me.ZE)(t);
        i &&
          (i.classList.add("page-image", "js-page-image"),
          this.appendPageImage(e, i, t.src));
      },
      {
        timeout: 1000,
      },
    );

再回头检查一下,哪里有choJuGiga
结果是内嵌在HTML的头部。观察抓包结果,HTML下发时就带有该JSON。
这也是GigaViewer的一大特征

<script id="episode-json" type="text/json" data-value=''>
{
  "readableProduct": {
    "showSquareThumbnailInRecommendation": false,
    "imageUrisDigest": "",
    "hasPurchased": false,
    "publishedAt": "",
    "prevReadableProductUri": null,
    "isPublic": true,
    "permalink": "4",
    "pageStructure": {
      "pages": [
        { "type": "other" },
        {
          "src": "",
          "width": 1688,
          "contentStart": "",
          "height": 2400,
          "type": "main"
        }
      ],
      "choJuGiga": "baku",
      "startPosition": "latter",
      "readingDirection": "rtl"
    },
    "number": 1,
    "typeName": "episode",
    "toc": null,
  }
}

现在我们知道,choJuGiga就表明该是否有加密,那么到底怎么解密呢?
回头仔细看看,发现const i = (0, me.ZE)(t),那我们就搜索ZE,就找到了这里:

55324: (e, t, i) => {
    i.d(t, {
    ZE: () => d
    });

对着混淆过的代码摸不着头脑的时候,往下稍稍一看:

class a extends r.$ {
  constructor(e, t) {
    (super(e),
      o(this, "DIVIDE_NUM", 4),
      o(this, "MULTIPLE", 8),
      o(this, "width", void 0),
      o(this, "height", void 0),
      o(this, "cell_width", void 0),
      o(this, "cell_height", void 0),
      (this.width = t.width),
      (this.height = t.height),
      (this.cell_width =
        Math.floor(this.width / (this.DIVIDE_NUM * this.MULTIPLE)) *
        this.MULTIPLE),
      (this.cell_height =
        Math.floor(this.height / (this.DIVIDE_NUM * this.MULTIPLE)) *
        this.MULTIPLE));
  }
  solve() {
    this.view.drawImage(0, 0, this.width, this.height, 0, 0);
    for (let e = 0; e < this.DIVIDE_NUM * this.DIVIDE_NUM; e++) {
      const t = Math.floor(e / this.DIVIDE_NUM) * this.cell_height,
        i = (e % this.DIVIDE_NUM) * this.cell_width,
        r = Math.floor(e / this.DIVIDE_NUM),
        n = (e % this.DIVIDE_NUM) * this.DIVIDE_NUM + r,
        s = (n % this.DIVIDE_NUM) * this.cell_width,
        o = Math.floor(n / this.DIVIDE_NUM) * this.cell_height;
      this.view.drawImage(i, t, this.cell_width, this.cell_height, s, o);
    }
  }
}

这里的逻辑已经很清晰了,对着重写一份即可。

マガポケ (Manga Pocket)

  • Web端固定分块数 图像打乱
  • 客户端无打乱
  • JPEG格式

发现其有手机客户端就下载了抓包试试,发现其客户端API提供的图像直接就是无打乱的。
3月份开始,MangaPocket对手机客户端也使用和网页端一致的固定分块数打乱图像,并且质量仅有Q75
(先前发送原图时可以获得高达Q87的图像)

因此我们高度不推荐该网站…
但来都来了,让我们看看其WEB端是如何解混淆的吧

抓包观察

https://se-api.pocket.shonenmagazine.com/web/episode/viewer下可以看到scramble_seed字段。

追踪解密流程

打开资源就能看到一个WASM,众所周知这玩意一般都是拿来协助加密用的。打开一看果不其然。
由于都是伪汇编,这里就不展示了…

这部分直接丢给AI给你解汇编即可,人确实不太擅长处理这种内容。
总之MangaPocket使用Xorshift32,利用scramble_seed计算出映射表

但是问题来了,这个scramble_seed它到底是怎么从字符串,算出来一个Xorshift32用的数值的?
这块有点太难了,WASM实在是解析不动,于是打算回头去客户端碰碰:
为什么客户端直接就是数值啊!

就…这样解决了?
所以解密流程就是:

  • scramble_seed作为Xorshift32哈希的Seed
  • 迭代16次,获取16个值,并同时保存生成的顺序,存放为[(0, <value_1>), (1, <value_2>),...]
  • 对哈希值进行升序排序,排序后的生成顺序就是原始分块位置

サイコミ (CyComi)

  • 二进制流加密(RC4)
  • JPEG格式

写到这里我们第一次遇到了直接加密图像二进制流的站点,这种形式好就好在不需要对原图像做二次编码,坏就坏在本地要对整个图片解一次。
这种加密方式给提供高质量JPEG提供了机会,只是CyComi似乎不想这么做。

抓包观察

没有明显的加密信息。

追踪解密流程

一打开栈跟踪就看见那个大大的loadEncryptImageData,真是好人啊。

W = (0, l.useMemo)(
  () => ({
    loadEncryptImageData: (e) => o(e),
    content: C,
    setContent: (e) => {
      n.current = e;
    },
    hasNextContent: T,
    hasPrevContent: F,
    nextContent: N,
    prevContent: L,
    nextPage: V,
    prevPage: _,
  }),
  [C, n, T, F, N, L, _, V],
);

这个loadEncryptImageData指向了o函数,往上查找一下这个函数。

s = n(36364);
async function o(e) {
  var t;
  if (!e) return;
  let n = null == (t = e.match(/\/([0-9a-zA-Z]{32})\//)) ? void 0 : t[1];
  if (!n) return e;
  let r = await a.A.get(e, {
      responseType: "arraybuffer",
    }),
    i = (0, s.M)(new Uint8Array(r.data), n),
    l = (
      await new Promise((e, t) => {
        let n = new FileReader();
        (n.addEventListener("error", t),
          n.addEventListener("load", () => e(n.result)),
          n.readAsDataURL(new Blob([i])));
      })
    ).split(",")[1];
  if (l) return ["data:null;base64", l].join(",");
}

这里读取了一段32字符的东西,再看看图像的URL构成,我们可以发现其解密用的密钥就在图像URL上。
然后我们追踪这里的s.M进一步查找解密的具体过程,可以看到s指向了36364,那我们就搜这个数字。

36364: (e, t, r) => {
  "use strict";
  r.d(t, {
    j: () => l,
    M: () => n,
  });
  let n = (e, t) => {
    let r = ((e) => {
        let t = new Uint8Array(256);
        t.forEach((e, r) => {
          t[r] = r;
        });
        let r = 0;
        return (
          t.forEach((n, i) => {
            r = (r + t[i] + e.charCodeAt(i % e.length)) % 256;
            let o = t[i];
            ((t[i] = t[r]), (t[r] = o));
          }),
          t
        );
      })(t),
      n = 0,
      i = 0,
      o = new Uint8Array(e.length);
    for (let t = 0, a = e.length; t < a; t++) {
      let a = 256;
      i = (i + r[(n = (n + 1) % 256)]) % a;
      let s = r[n % 256];
      ((r[n % 256] = r[i]), (r[i] = s));
      let l = r[(r[n] + r[i]) % a];
      o[t] = l ^ e[t];
    }
    return o;
  };
};

从这里可以看到,CyComi 使用的是非常标准的 RC4算法

小学馆旗下站点

  1. マンガワン (MangaOne)
  2. 週刊コロコロコミック (CoroCoro)
  • 二进制流加密(AES-256)
  • Protobuf数据传输
  • WebP格式

欢迎来到新时代!
写到这里我们第一次遇上了采用WebP格式的新世代漫画站点。并且我测试过该网站似乎没有JPEG Fallback。

小学馆系统最突出的特色是其采用Protobuf传输数据,而非传统的JSON,这也导致了我们要获知每个字段的准确含义变得更麻烦了。

下面以 マンガワン (MangaOne) 为例,展示如何逆向这套方案

抓包观察

发现传输的数据是二进制流,不是JSON,并且在HEX视图可以看到数据中有URL等信息。

追踪解密流程

发现图像栈跟踪和断点均无法找到对应的解密流程,所以我们只能换换思路了。
在抓包观察中,发现其图像URL是通过/api/client?rq=viewer_v2下发的,那我们就搜索viewer_v2追踪图像下发全流程。

let a = (e) => ({
  path: "viewer_v2",
  parameters: (0, n.G)(e),
  method: "post",
  decode: (e) => s.u.v4.WebViewerResponse.decode(new Uint8Array(e)),
});

可以看到其有个消息解码的函数,指向WebViewerResponse.decode,那么我们就找WebViewerResponse

e.WebViewerResponse = function () {
  let e;
  function t(e) {
    if (((this.pages = []), (this.recommendedTitles = []), e))
      for (let t = Object.keys(e), n = 0; n < t.length; ++n)
        null != e[t[n]] && (this[t[n]] = e[t[n]]);
  }
  return (
    (t.prototype.pages = l.emptyArray),
    (t.prototype.aesKey = ""),
    (t.prototype.aesIv = ""),
    (t.prototype.currentTitle = null),
    (t.prototype.author = null),
    (t.prototype.currentChapter = null),
    ...
  );
  ...
};

这里的WebViewerResponse是一个Protobuf Message,我们可以发现其数据中有两个参数很引人注目:aesKeyaesIv
那么图像就大概率是AES加密了。
接下来的事就简单了:直接搜索aesKey,看看是哪个地方用了它:

if (e.image) {
  let s = e.image,
    r = j(null !== (i = s.pageSide) && void 0 !== i ? i : void 0);
  return {
    ...g,
    src: null !== (n = s.imageUrl) && void 0 !== n ? n : "",
    sizeRatio: (null == s ? void 0 : s.width)
      ? (null !== (a = null == s ? void 0 : s.height) && void 0 !== a ? a : 0) /
        (null == s ? void 0 : s.width)
      : 0,
    crypto:
      (null == t ? void 0 : t.aesIv) && t.aesKey
        ? {
            method: "aes-cbc",
            key: t.aesKey,
            iv: t.aesIv,
          }
        : void 0,
    side:
      null !== (l = null == r ? void 0 : r.side) && void 0 !== l ? l : void 0,
  };
}

所以使用AES CBC解密即可。
Protobuf的具体定义可以在前面我们提到的WebViewerResponse中找到,下面有一大堆Switch...Case...,根据那个自己编写一份Protobuf即可。

カドコミ (Kadokawa Comic / ComicWalker)

  • 二进制流加密(XOR)
  • WebP格式

抓包观察

{
  "promotionsEnd": [],
  "labelLogo": "",
  "scrollDirection": "rtl",
  "expiresAt": "",
  "startPosition": "left",
  "displayAds": true,
  "manuscripts": [
    {
      "drmMode": "xor",
      "drmHash": "c0f2759b27ca8baf",
      "drmImageUrl": "",
      "page": 1,
      "width": 768,
      "height": 1085
    }
  ]
}

已经把加密方式给我直接标出来了,真的很好心啊。
那要做的就很简单了:直接搜xor,或者都知道是XOR,那直接套基础的XOR算法也不是不行。

switch (e) {
  case "raw":
    return new rq(r);
  case "xor":
    return rV.create({
      sourceUrl: r,
      hash: t,
    });
}

可以看到其只有rawxor两种形式。没有别的加密形式了。

class rV {
  constructor(e, t) {
    ((this.sourceUrl = e), (this.xorKey = t));
  }
  #e = !1;
  static create(e) {
    return new rV(e.sourceUrl, this.populateKey(e.hash));
  }
  static populateKey(e) {
    let t = e.slice(0, 16).match(/[\da-f]{2}/gi);
    if (eH(t)) throw Error("error");
    return new Uint8Array(t.map((e) => parseInt(e, 16)));
  }
  static xor(e) {
    return (t) => {
      let { length: r } = t,
        { length: n } = e,
        i = new Uint8Array(r);
      for (let o = 0; o < r; o += 1) i[o] = t[o] ^ e[o % n];
      return i;
    };
  }
}

就是非常标准的XOR算法,直接用drmHash跑这个就行了。

ヤンジャン+ (Young Jump+)

  • 固定分块数 图像打乱
  • WebP格式

抓包观察

无明显密钥数据。

追踪解密流程

图片可以栈跟踪,没几下就会看到

((c = P(() => a.mangaPage.pageImageUrl)),
  (g = P(() => a.mangaPage.imageHorizontalSize)),
  (r = P(() => a.mangaPage.imageVerticalSize)),
  (p = Math.floor(g.value / ne)),
  (s = Math.floor(r.value / ne)),
  (n = q(!1)),
  (h = q(!1)),
  (V = q(!1)));
let E = null;
const m = async () => {
  if (!(n.value || h.value)) {
    ((h.value = !0), (i.value.width = g.value), (i.value.height = r.value));
    try {
      E.clearRect(0, 0, g.value, r.value);
      const k = await Nt(c.value);
      if ((E.drawImage(k, 0, 0, g.value, r.value), !a.isBenefitViewer))
        for (let C = 0; C < ne * ne; C++) {
          const b = Math.floor(C / ne) * s,
            M = (C % ne) * p,
            z = Math.floor(C / ne),
            G = (C % ne) * ne + z,
            j = (G % ne) * p,
            U = Math.floor(G / ne) * s;
          E.drawImage(k, M, b, p, s, j, U, p, s);
        }
      ((n.value = !0), (h.value = !1));
    } catch {
      ...
    }
  }
};

居然是传统的固定分块位置。搜索一下这个ne变量,可以发现其是定值4
照着转写即可。

マンガPark (Manga Park)

  • 二进制流加密(XOR)
  • JPEG格式

十分传统派的网站,JS没有压缩过,也没有分块,功能职责非常清晰。

抓包观察

{
  "csrf_token": "",
  "is_logged_in": false,
  "data": {
    "chapter": [
      {
        "images": [
          {
            "path": "",
            "width": 0,
            "height": 0,
            "key": "QfX+Z3qY3I64c6sIuVV4cQ=="
          }
        ],
        "width": 0,
        "height": 0
      }
    ],
    "consume_type": "free",
    "page_start": "LEFT"
  }
}

这有个看着就像Base64编码的KEY。

追踪解密流程

发现图像可以栈跟踪,点两下就看到这个地方:

{
  key: 'loadMinobiImage',
  value: function (e, n) {
    var o = this;
    return new t(
      function (t, r) {
        var i = o.decodeB64(n),
        a = new XMLHttpRequest;
        a.onreadystatechange = function () {
          if (4 === a.readyState) if (200 === a.status) {
            var n = new Uint8Array(a.response);
            if (i) for (var o = i.length, s = 0; s < n.length; s++) n[s] ^= i[s % o];
            var u = URL.createObjectURL(new Blob([n], {
              type: 'image/jpeg'
            }));
            t(u)
          } else console.error('We can\'t load file: ', e, a),
          r()
        },
        a.onerror = r,
        a.open('GET', e, !0),
        a.responseType = 'arraybuffer',
        a.send(null)
      }
    )
  }
}

就是Base64解码Key之后,对图像二进制流进行标准XOR解密操作即可。

ニコニコ漫画 (NicoNico Manga)

  • 二进制流加密(XOR)
  • WebP格式

追踪解密流程

因为这个比较简单,我们就跳过抓包观察这一步了。
直接看加载的图像的栈跟踪,可以看见_ImageLoader</this.getFromCORSRequest这一步调用

this.getFromCORSRequest = function (e, n, r) {
  if (/^data:/.test(n)) return r(n);
  var i = this.getKeyFromUrl(n);
  e.open('GET', n, !0),
  e.responseType = 'arraybuffer',
  e.onload = function (e) {
    var t = new Uint8Array(this.response);
    '' !== i &&
    (t = o.decrypt(t, i));
    t = 'data:image/' + o.getDataType(t) + ';base64,' + o.toBase64String(t);
    a[n] = t,
    r(t)
  },
  e.send()
}

进来就发现其调用了getKeyFromUrl,我们就要敏感地意识到加密使用的密钥可能就是从这里获得的。
观察代码发现密钥就在URL里。

this.getKeyFromUrl = function (e) {
      e = e.match('/image/([a-z0-9_]+)/');
      return null === e ? '' : e[1].split('_') [0]
    }

然后继续向下,眼尖的你肯定立刻看到了o.decrypt(t, i)
而这一函数块的头部正赫然写着var o = this

this.decrypt = function (e, t) {
  for (var n = [], r = 0; r < 8; r++) n.push(parseInt(t.substr(2 * r, 2), 16));
  for (r = 0; r < e.length; r++) e[r] = e[r] ^ n[r % 8];
  return e
}

看看代码,正是我们熟悉的XOR。

抓包观察

我们回过头来,有一个问题还没解决:这图的URL是哪来的?
使用Charles抓包,然后直接搜索图片URL后发现,这部分信息全部塞在漫画页面的<script "type="text/javascript">

pixivコミック (pixiv Comic)

  • 固定分块尺寸 图像打乱
  • JPEG格式

抓包观察

分块数非常巨大,看着非常恐怖。
/read_v4下可以找到各图像的URL,同时还有一个key
仔细观察URL还会发现路径上有写JPEG Quality和具体的分块尺寸。

{
  "data": {
    "reading_episode": {
      ...
      "pages": [
        {
          "url": "/c/q90_gridshuffle32:32/images/page/",
          "height": 1024,
          "width": 720,
          "gridsize": 32,
          "key": "7e9135b1-06b9-4e1c-96da-184488675102"
        }
      ],
    }
  }
}

追踪解密流程

由于图像没法栈跟踪,选择直接搜索key,看看这个key用到哪里去了,结果很快就发现:

a.onload = async () => {
  if (i) {
    i.drawImage(a, 0, 0);
    let n = i.getImageData(0, 0, r.width, r.height),
      o = new ImageData(
        await (0, s.T)(
          n.data,
          4,
          r.width,
          r.height,
          r.gridsize,
          r.gridsize,
          "(此处略去)",
          r.key,
          !0,
        ),
        r.width,
        r.height,
      );
    (i.putImageData(o, 0, 0),
      l.toBlob((r) => {
        r ? e(URL.createObjectURL(r)) : t();
      }));
  } else t();
};

直接给这里下断点,看看这个s.T指向什么地方?

let i = globalThis.crypto;
async function o(e, t, a, r, o, s, l, d, c) {
let u = Math.ceil(r / s),
  g = Math.floor(a / o),
  p = Array(u)
    .fill(null)
    .map(() => Array.from(Array(g).keys()));
{
  let e = new TextEncoder().encode(l + d),
    t = new n(new Uint32Array(await i.subtle.digest("SHA-256", e), 0, 4));
  for (let e = 0; e < 100; e++) t.next();
  for (let e = 0; e < u; e++) {
    let a = p[e];
    for (let e = g - 1; e >= 1; e--) {
      let r = t.next() % (e + 1),
        n = a[e];
      ((a[e] = a[r]), (a[r] = n));
    }
  }
}
if (c)
  for (let e = 0; e < u; e++) {
    let t = p[e],
      a = t.map((e, a) => t.indexOf(a));
    if (a.some((e) => e < 0)) throw Error("Failed to reverse shuffle table");
    p[e] = a;
  }
let h = new Uint8ClampedArray(e.length);
for (let n = 0; n < r; n++) {
  let r = p[Math.floor(n / s)];
  for (let i = 0; i < g; i++) {
    let s = r[i],
      l = (n * a + i * o) * t,
      d = (n * a + s * o) * t,
      c = o * t;
    for (let t = 0; t < c; t++) h[l + t] = e[d + t];
  }
  {
    let r = (n * a + g * o) * t,
      i = (n * a + a) * t;
    for (let t = r; t < i; t++) h[t] = e[t];
  }
}
return h;
}

这里还要用到一个n,往上翻一下或者打个断点就能追踪到了:

class n {
  next() {
    let e = (9 * r((5 * this.s[1]) >>> 0, 7)) >>> 0,
      t = (this.s[1] << 9) >>> 0;
    return (
      (this.s[2] = (this.s[2] ^ this.s[0]) >>> 0),
      (this.s[3] = (this.s[3] ^ this.s[1]) >>> 0),
      (this.s[1] = (this.s[1] ^ this.s[2]) >>> 0),
      (this.s[0] = (this.s[0] ^ this.s[3]) >>> 0),
      (this.s[2] = (this.s[2] ^ t) >>> 0),
      (this.s[3] = r(this.s[3], 11)),
      e
    );
  }
  constructor(e) {
    if (4 !== e.length)
      throw Error("seed.length !== 4 (seed.length: ".concat(e.length, ")"));
    ((this.s = new Uint32Array(e)),
      0 === this.s[0] &&
        0 === this.s[1] &&
        0 === this.s[2] &&
        0 === this.s[3] &&
        (this.s[0] = 1));
  }
}

pixiv Comic 的解混淆过程非常健壮而复杂,我没这个水平读通,所以就交给AI吧。
我梳理一下输出,大概是这样的:

  1. 准备阶段
    • 将固定前缀与Key拼接,用SHA-256哈希处理拼接后的字符串
    • 取哈希值的前16字节,构造包含4个UInt32的数组
    • 这4个32位整数作为种子,进行xoshiro128**伪随机,并连续调用100次预热
  2. 构建洗牌表
    • 根据图像宽高和网格大小算出切分的列数u和行数g
    • 对图像每一行初始化一个索引数组
    • 用伪随机数执行Fisher-Yates / Knuth洗牌算法,洗牌索引数组
    • c参数为true,计算洗牌表的逆映射
  3. 重组
    • 使用逆向洗牌表r获取切片对应的原始位置,将其搬回
    • 对于无法整除gridsize的剩余像素,直接原样拷贝

Renta! (レンタ)

  • 固定分块数 图像打乱
  • JPEG格式

Renta! 在线阅读使用的JPEG切片仅有Quality 70,非常惨不忍睹,高度不推荐该站点
但是来都来了,我们就来逆向一下它的算法吧

追踪解密流程

在XHR一眼看到了图像二进制流的传输,我原本以为会是二进制流加密,还高兴了一下。
发现有栈跟踪,第一个就是getImageData,马上跟进去:

var a = t.target.response;
++now_load_pos >= max_data_len && (now_load_pos = 0);
for (var n = new Uint8Array(a), r = '', i = 0; i < 9; i++) {
    r += String.fromCharCode(n[i]);
}

上来先读了9个字节,并转换为字符。让我们看看对应的内容(已decode):000000596
是ASCII编码的头部长度。

if (isNaN(r)) cdata1 = '';
else {
    for (i = 9; i < r + 9; i++) o += String.fromCharCode(n[i]);
    var l = o.split('|');
    data1_w = l[0],
    data1_h = l[1]
}

然后读取了整个头部。

844|1200|0,2749|2749,2238|4987,6104|11091,...

头部以|号切分,第一和第二部分是图像的长和高,后面的部分又以,二次切分,是<去掉头部后的偏移>,<数据长度>
通过十六进制编辑器观察,可以看到对应的位置有0xFFD8标头,还有JFIF标志,就是JPEG分片。

然后我们来看打乱算法

// 读取头数据
var c = (l = o.split("|"))[0],
  m = l[1], 
  g = l[2], // 后面还原出来之后观察可以发现,第一张图是宽度%7的部分。即宽度平均切分7份后遗留下来的部分。
  p = l[3]; // 第二张图是长度%7的部分。
l = l.splice(4); // 剩余的图像
(parseInt(c / 7), parseInt(m / 7)); // 固定分块数:长宽各7,总计49块
var f = Math.ceil(c % 7),
  h = Math.ceil(m % 7),
  u = "",
  y = ((s = 0), new Array(7));
// 生成 7*7 矩阵
for (i = 0; i < 7; i++) {
  y[i] = new Array(7);
  for (var w = 0; w < 7; w++) ((y[i][w] = s), s++);
}
// 第一轮打乱:行循环移位
for (i = 0; i < 7; i++) {
  var v = new Array(7),
    _ = 7 - (i % 7);
  for (w = 0; w < 7; w++) (_ >= 7 && (_ = 0), (v[w] = y[i][_]), (_ += 1));
  for (w = 0; w < 7; w++) y[i][w] = v[w];
}
// 第二轮打乱:列循环移位
for (i = 0; i < 7; i++) {
  for (v = new Array(7), _ = 7 - (i % 7), w = 0; w < 7; w++)
    (_ >= 7 && (_ = 0), (v[w] = y[_][i]), (_ += 1));
  for (w = 0; w < 7; w++) y[w][i] = v[w];
}
// 第三轮打乱:按种子洗牌
for (i = 0; i < 7; i++) {
  var C = i + 1,
    I = parseInt(e) + parseInt(prd_ser); // 首先计算因子I,这里的传入参数e是页码,prd_ser在图像URL中。
  I % 20 == 0 && (I = Math.abs(e - prd_ser) + 21); // 如果I可以整除20,则使用特殊算法计算I
  for (w = parseInt((C * I + e / 20) % 20) - 1; w >= 0; w--) // 计算出这一环节的打乱次数
    y = fShuffleR(y, w, i, 7);
}

行循环和列循环移位都非常基础,不再进一步解释了。
让我们重点看看fShuffleR的实现:

function fShuffleR(e, t, a, n) {
  var r = kn = parseInt(n / 2);
  n % 2 != 0 &&
  (r += 1);
  var i = new Array(r),
  o = new Array(kn),
  l = k_cnt = 0,
  s = new Array(n),
  d = 0;
  if (t % 2 == 0) { // 根据传入参数t(即上文中的w)是奇还是偶进入不同的处理
    for (var c = 0; c < n; c++) c % 2 == 0 ? (i[l] = e[c][a], l++) : (o[k_cnt] = e[c][a], k_cnt++); // 对本段数据按下标奇偶切分
    for (c = 0; c < r; c++) void 0 != o[c] && // 交叉插入奇偶索引,一奇一偶
    (s[d] = o[c], d++),
    void 0 != i[c] &&
    (s[d] = i[c], d++)
  } else {
    for (c = 0; c < n; c++) c < r ? (i[l] = e[c][a], l++) : (o[k_cnt] = e[c][a], k_cnt++); // 对本段数据按前半后半切分
    for (c = 0; c < r; c++) void 0 != i[c] && // 交叉插入前后索引,一前一后
    (s[d] = i[c], d++),
    void 0 != o[c] &&
    (s[d] = o[c], d++)
  }
  for (c = 0; c < n; c++) e[c][a] = s[c];
  return s = null,
  i = null,
  o = null,
  e
}

整体来说不难,但是图像质量太烂了,非常不推荐。

无加密站点

不是哥们,原图直出吗。

ガンガンONLINE (GanGanOnline)

  • WebP格式

ゼロサムオンライン (ZeroSum Online)

  • WebP格式

アルファポリス (Alphapolis)

  • JPEG格式

Manga-Website 漫画街

  • JPEG格式

这网站真的有点复古了

Renta! TW(亂搭)

  • JPEG格式

(你这站点是日本站点吗?)

posted @ 2026-02-22 00:35  yyfll  阅读(186)  评论(0)    收藏  举报