一、先弄清楚卡在哪里了

搞 Solana 开发的人,多多少少都会碰到想看看别人合约内部实现的时候。不管是做安全审计、找漏洞,还是单纯想学学别人的设计思路,第一反应都是“反编译它”。但真动手的时候你会发现,这条路经常走不通。网上能找到的反编译工具少得可怜,就算找到一两个,反编译出来的代码也跟天书似的,根本没法看。为什么会这样呢?

因为 Solana 的程序不是跑在以太坊那种 EVM 虚拟机上,而是跑在自定义的 BPF 虚拟机里。它的字节码格式跟 EVM 完全不一样,市面上成熟的逆向工具基本都优先支持 EVM,Solana 这边就没什么人管。再加上很多合约上线前会做混淆,或者压根不公开源码,你就只剩下一堆二进制文件。这时候硬要反编译,就像对着一个焊死的铁盒子猜里面的电路,费劲不说,还容易猜错。

但别灰心,反编译不了不代表没法逆向。我们可以换个思路:不直接去还原源码,而是观察这个程序在链上的“行为”。就像你猜不透一个人的想法,但你可以通过他的行动来判断他的意图。这就是动态分析,也是这篇文章要聊的核心。

1.1 为什么反编译会碰壁

Solana 程序编译后生成的是 BPF 字节码,它不是通用的机器码,也没有像 IDA 或者 Ghidra 那样能直接反编译的成熟插件。网上有一些工具能把字节码转成类似汇编的格式,但读汇编本来就费劲,再加上 Solana 的程序结构复杂,比如有账户映射、指令分发、序列化等等,直接看汇编很容易迷失。更麻烦的是,很多合约会故意增加一些无用代码来干扰分析,让逆向难度又上一个台阶。

1.2 换个思路:动态观察

既然静态分析走不通,那就让程序自己“说话”。我们可以通过构造不同的交易输入,去调用这个程序,然后观察它的状态变化、日志输出,甚至让它主动暴露出内部逻辑。这个方法听起来很笨,但往往最有效,尤其是对付那些混淆过的合约。

二、逆向思路:从字节码到逻辑

2.1 把合约当黑盒,用输入输出猜逻辑

我们先不关心合约内部怎么写的,只把它当成一个黑盒。黑盒外面有很多按钮(指令),每个按钮代表一种操作。我们按下一个按钮,看看会有什么反应,比如某个账户余额变了,或者某个数据被修改了。通过反复试,我们就能慢慢摸清每个按钮是干什么的。

在 Solana 上,这个“按钮”就是指令(Instruction)。一条指令包含目标程序 ID、账户列表和一段字节数组。字节数组就是指令数据,通常第一个字节是指令类型,后面跟着参数。我们可以用 TypeScript 写一个小工具,构造各种指令数据,然后发到链上,观察结果。

下面是一个构造转账指令并发送的示例,注意我们用的是 TypeScript 和 @solana/web3.js 这个官方库。

// 技术栈:TypeScript + @solana/web3.js

import {
  Connection,
  Keypair,
  PublicKey,
  Transaction,
  TransactionInstruction,
  LAMPORTS_PER_SOL
} from '@solana/web3.js';

// 连接 Solana 测试网
const connection = new Connection('https://api.testnet.solana.com', 'confirmed');

// 假设目标程序地址(这里写个占位符)
const programId = new PublicKey('目标程序ID');

// 构造指令数据:前 1 字节为指令类型,后面跟 8 字节小端序金额
function createTransferData(amount: number): Buffer {
  const type = Buffer.from([0x01]); // 假设 0x01 代表转账
  const amountBuf = Buffer.alloc(8);
  amountBuf.writeBigUInt64LE(BigInt(amount)); // 写入 8 字节小端序
  return Buffer.concat([type, amountBuf]);
}

async function main() {
  // 生成发送方和接收方账户
  const payer = Keypair.generate();
  await connection.requestAirdrop(payer.publicKey, 1 * LAMPORTS_PER_SOL);

  const receiver = Keypair.generate();

  // 创建指令,传入账户列表和指令数据
  const instruction = new TransactionInstruction({
    keys: [
      { pubkey: payer.publicKey, isSigner: true, isWritable: true },
      { pubkey: receiver.publicKey, isSigner: false, isWritable: true }
    ],
    programId,
    data: createTransferData(1000) // 尝试转 1000
  });

  // 发送交易
  const tx = new Transaction().add(instruction);
  const signature = await connection.sendTransaction(tx, [payer]);
  await connection.confirmTransaction(signature);
  console.log('交易成功,签名:', signature);
}

main().catch(console.error);

你可能会问:光发一笔交易有什么用?关键在“观察”。你可以接收方账户的余额变化,也可以查看交易日志。如果交易失败了,日志里会告诉你原因,比如“无效指令类型”或者“参数错误”。根据这些错误提示,你就能反推指令格式。比如你把第一个字节改成 0x02,发现报错“无效指令”,那说明 0x01 很可能就是合法的指令类型。

2.2 利用指令表做对照

有些程序虽然没有开源,但网上可能有人整理过它的指令表,比如“0x01 是转账,0x02 是授权”。我们可以把这些信息收集起来,作为猜测的起点。如果没有现成的,那就只能暴力遍历了。把第一个字节从 0 到 255 挨个试,观察哪个不报错。当然,这种方法比较慢,但胜在简单。记得在测试网上试,别在主网上乱发交易。

2.3 让程序跑起来,看它怎么动

有时候发几笔交易还不够,我们想让程序“跑”起来,单步执行,观察每一步的状态。Solana 提供了一个本地测试节点 solana-test-validator,我们可以把目标程序部署到本地,然后用调试工具单步执行。不过这个操作比较繁琐,需要熟悉 Rust 和 BPF 的调试工具链。对于大多数逆向场景,我们更常用的是另一种手段——日志分析。Solana 在执行程序时,会打印非常详细的日志,尤其是跨程序调用的时候,这正好引出了我们下面要讲的重头戏。

三、CPI调用栈追踪技巧

3.1 CPI是个啥,为啥要盯紧它

CPI 的全称是 Cross-Program Invocation,也就是跨程序调用。Solana 的程序可以调用其他程序,就像以太坊里一个合约调用另一个合约。很多复杂的业务逻辑都会拆分到多个程序里,主程序只负责入口,实际干活的可能是别的程序。比如说,一个借贷协议的主程序可能会调用一个代币程序来转账,如果代币程序出了问题,整个借贷就完了。

所以逆向的时候,我们特别需要搞清楚:这个程序调用了哪些别的程序?调用顺序是什么?参数怎么传的?这些信息能帮我们找到隐藏的逻辑,尤其是那些利用 CPI 做坏事的行为。比如某些攻击合约会故意调用一个恶意程序,然后通过权限传递漏洞偷走资产。

3.2 从交易日志里扒出调用关系

好消息是,Solana 的交易日志里天然记录了每一次 CPI 调用的痕迹。一个典型的日志长这样:

Program 1111 invoke [1]
Program 2222 invoke [2]
Program 2222 success
Program 1111 success

第一行表示程序 1111 被调用,层级是 1;第二行表示程序 1111 调用了程序 2222,层级变成 2;第三行表示 2222 执行成功;第四行表示 1111 执行成功。我们可以用 TypeScript 写一个脚本,把交易日志抓下来,然后解析出调用关系。

下面是一个获取交易日志并打印 CPI 调用过程的示例:

// 技术栈:TypeScript + @solana/web3.js

import { Connection } from '@solana/web3.js';

// 连接主网(或者你需要的网络)
const connection = new Connection('https://api.mainnet-beta.solana.com');

// 交易签名(从区块浏览器里复制)
const txSignature = '你的交易签名';

async function traceCPI(signature: string) {
  // 获取交易详情,注意要指定版本
  const tx = await connection.getTransaction(signature, {
    maxSupportedTransactionVersion: 0
  });

  if (!tx) {
    console.log('没找到这笔交易');
    return;
  }

  const logs = tx.meta?.logMessages;
  if (!logs) {
    console.log('没有日志');
    return;
  }

  // 逐行扫描日志,通过正则匹配 invoke 和 success/failed
  let depth = 0;
  for (const log of logs) {
    const invokeMatch = log.match(/Program (\w+) invoke \[(\d+)\]/);
    if (invokeMatch) {
      const programId = invokeMatch[1];
      depth = parseInt(invokeMatch[2], 10);
      // 用缩进表示层级,看起来更清楚
      console.log(`${'  '.repeat(depth)}调用了程序: ${programId}`);
      continue;
    }

    // 匹配 success 或 failed,注意可能是 "Program log: " 开头
    if (/Program (\w+) success/.test(log)) {
      console.log(`${'  '.repeat(depth)}-> 成功`);
    } else if (/Program (\w+) failed/.test(log)) {
      console.log(`${'  '.repeat(depth)}-> 失败`);
    }
  }
}

traceCPI(txSignature).catch(console.error);

这个脚本只能粗略地打印调用过程,但已经足够看出调用了哪些程序。如果你发现日志里出现了一个你不认识的程序 ID,那就得警惕了——可能这个合约在偷偷调用什么危险的东西。

3.3 构建一棵调用树

光打印调用过程还不够,我们最好能构建一棵调用树,清晰地显示嵌套关系。比如程序 A 调用了程序 B,程序 B 又调用了程序 C,那么调用树就是:

A
  B
    C

我们可以用 TypeScript 写一个简单的树结构,把日志里的 invoke 和 success/failed 配对,然后组装成树。

// 技术栈:TypeScript

// 定义调用树节点
interface CallNode {
  programId: string;
  success: boolean;
  children: CallNode[];
}

// 根据日志构建调用树
function buildCallTree(logs: string[]): CallNode[] {
  const roots: CallNode[] = [];
  const stack: CallNode[] = [];

  for (const log of logs) {
    // 匹配 invoke 行
    const invokeMatch = log.match(/Program (\w+) invoke \[(\d+)\]/);
    if (invokeMatch) {
      const node: CallNode = {
        programId: invokeMatch[1],
        success: false,
        children: []
      };

      // 如果栈不为空,说明这个节点是栈顶节点的子节点
      if (stack.length > 0) {
        stack[stack.length - 1].children.push(node);
      } else {
        roots.push(node);
      }

      // 当前节点入栈
      stack.push(node);
      continue;
    }

    // 匹配 success 或 failed 行
    const successMatch = log.match(/Program (\w+) success/);
    const failedMatch = log.match(/Program (\w+) failed/);
    if (successMatch || failedMatch) {
      // 栈顶节点执行结束,标记成功或失败,然后出栈
      if (stack.length > 0) {
        const finished = stack.pop()!;
        finished.success = !!successMatch;
      }
    }
  }

  return roots;
}

// 递归打印调用树
function printTree(nodes: CallNode[], prefix = '') {
  for (const node of nodes) {
    const status = node.success ? '成功' : '失败';
    console.log(`${prefix}${node.programId} (${status})`);
    printTree(node.children, prefix + '  ');
  }
}

// 示例日志
const sampleLogs = [
  'Program 1111 invoke [1]',
  'Program 2222 invoke [2]',
  'Program 2222 success',
  'Program 1111 success'
];

const tree = buildCallTree(sampleLogs);
printTree(tree);

运行这段代码,你会看到:

1111 (成功)
  2222 (成功)

这样调用关系就一目了然了。在实际分析中,日志里还会有很多额外的干扰信息,比如 Program log: ...,你需要过滤掉那些行。上面代码里我们只匹配 invokesuccess/failed,所以不会受影响。

3.4 注意权限传递的细节

追踪 CPI 时,除了看调用关系,还要特别注意权限的传递。在 Solana 里,程序 A 调用程序 B 时,需要把一些账户传给 B。这些账户里可能包含 isSigner: true 的签名账户,也就是说,B 可以代表用户进行某些操作。如果 A 没有正确检查签名就调用了 B,那就有可能被恶意利用。

怎么通过日志看出权限传递呢?直接看日志看不到账户列表,但你可以从指令数据里解析。指令数据中通常会包含账户索引,指明哪些账户是给 CPI 用的。我们可以写一个小工具,把指令数据里的账户索引解析出来,然后对照交易中的账户列表,看看是否有签名者权限被传递了。

下面是一个简单的解析示例,假设指令数据的前 4 个字节是账户索引数量,后面跟着若干索引。

// 技术栈:TypeScript

// 解析指令数据中的账户索引列表
function parseAccountIndices(data: Buffer): number[] {
  // 前 4 字节是索引数量(小端序)
  const count = data.readUInt32LE(0);
  const indices: number[] = [];

  // 后面每个索引占 1 字节
  for (let i = 0; i < count; i++) {
    indices.push(data.readUInt8(4 + i));
  }
  return indices;
}

// 示例指令数据
// 假设数据为:0x02 0x00 0x00 0x00 0x00 0x01
// 表示有 2 个索引,分别是 0 和 1
const data = Buffer.from([0x02, 0x00, 0x00, 0x00, 0x00, 0x01]);
const indices = parseAccountIndices(data);
console.log('账户索引列表:', indices); // 输出 [0, 1]

// 结合交易中的账户列表,就能判断这些索引对应的账户是否签名

当然,真实程序的指令格式五花八门,需要你根据具体情况去猜。但核心思路是:把字节流拆开,找出里面的账户索引,再对照账户列表的签名状态,就能判断权限传递是否安全。

四、应用场景与优缺点

4.1 这些技巧都用在哪

这套思路最常见的用途就是安全审计。比如你在审计一个借贷协议时,发现它的主程序调用了一个代币程序,但代币程序的权限校验有漏洞,那整个协议都可能被攻击。用 CPI 调用栈追踪,你能快速定位到风险点。

另外,分析恶意合约也很管用。很多钓鱼合约会伪装成正规项目,然后私下调用一些异常程序。你把交易日志拉出来,构建调用树,很快就能发现它调用了什么不该调用的东西。

还有就是竞品分析。你想看看别人的合约是怎么实现某个功能的,虽然拿不到源码,但通过黑盒测试和 CPI 追踪,也能把核心流程猜个七七八八。

4.2 好使的地方和让人头疼的地方

这些方法的优点很明显:不需要依赖反编译工具,就算合约混淆得再厉害,只要它要跟外界交互,就会暴露行为;而且日志是链上真实记录,不会骗人。

缺点也有:你只能看到程序的外部行为,无法还原内部的具体算法和变量计算过程。比如你看到它调用了某个程序,但不知道它为什么这么调用,参数是怎么算出来的。这时候你可能还得回到静态分析,去啃字节码。另外,黑盒测试需要构造大量交易,效率不高,而且有些程序只在特定条件下才会执行关键路径,你得想办法触发它。

五、注意事项

第一,别在主网上乱发交易。所有测试都在测试网或者本地验证节点上进行,不然一不小心可能造成资产损失。

第二,注意隐私。你的密钥对不要泄露,尤其是生成临时账户的时候,用完就扔。

第三,关注 Solana 的版本变化。现在很多交易是版本化交易(Versioned Transaction),获取交易时如果不指定 maxSupportedTransactionVersion,可能会拿不到数据。

第四,日志可能被截断。如果交易太大,日志信息可能会不完整,这时候你可以尝试分段获取,或者用更底层的 RPC 方法。

第五,别只看调用树,还要结合账户数据的变化。有时候 CPI 调用了很多层,但真正改数据的只有最底层那个程序,你得通过余额或状态变化来验证自己的推断。

六、总结

反编译受阻不意味着逆向结束。在 Solana 的世界里,我们可以把合约当成一个黑盒,通过构造输入观察输出来猜逻辑;也可以利用交易日志里的 CPI 痕迹,一步一步还原出整个调用关系。这些方法虽然不像反编译那样能直接给你源码,但在大多数场景下已经够用了。希望这篇文章里的思路和代码,能帮你少踩几个坑,在逆向的路上走得更顺一点。