如何反混淆2001年IOCCC赢家ctk.c代码?

6

我看过ctk.c混淆代码,但我该如何开始反混淆呢?


(说明:该段内容涉及IT技术)
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <sys/time.h>
#include <signal.h>
#define m(b)a=b;z=*a;while(*++a){y=*a;*a=z;z=y;}
#define h(u)G=u<<3;printf("\e[%uq",l[u])
#define c(n,s)case n:s;continue
char x[]="((((((((((((((((((((((",w[]=
"\b\b\b\b\b\b\b\b\b\b\b\b\b\b\b\b\b\b\b\b\b\b";char r[]={92,124,47},l[]={2,3,1
,0};char*T[]={"  |","  |","%\\|/%"," %%%",""};char d=1,p=40,o=40,k=0,*a,y,z,g=
-1,G,X,**P=&T[4],f=0;unsigned int s=0;void u(int i){int n;printf(
"\233;%uH\233L%c\233;%uH%c\233;%uH%s\23322;%uH@\23323;%uH \n",*x-*w,r[d],*x+*w
,r[d],X,*P,p+=k,o);if(abs(p-x[21])>=w[21])exit(0);if(g!=G){struct itimerval t=
{0,0,0,0};g+=((g<G)<<1)-1;t.it_interval.tv_usec=t.it_value.tv_usec=72000/((g>>
3)+1);setitimer(0,&t,0);f&&printf("\e[10;%u]",g+24);}f&&putchar(7);s+=(9-w[21]
)*((g>>3)+1);o=p;m(x);m(w);(n=rand())&255||--*w||++*w;if(!(**P&&P++||n&7936)){
while(abs((X=rand()%76)-*x+2)-*w<6);++X;P=T;}(n=rand()&31)<3&&(d=n);!d&&--*x<=
*w&&(++*x,++d)||d==2&&++*x+*w>79&&(--*x,--d);signal(i,u);}void e(){signal(14,
SIG_IGN);printf("\e[0q\ecScore: %u\n",s);system("stty echo -cbreak");}int main
(int C,char**V){atexit(e);(C<2||*V[1]!=113)&&(f=(C=*(int*)getenv("TERM"))==(
int)0x756E696C||C==(int)0x6C696E75);srand(getpid());system("stty -echo cbreak"
);h(0);u(14);for(;;)switch(getchar()){case 113:return 0;case 91:case 98:c(44,k
=-1);case 32:case 110:c(46,k=0);case 93:case 109:c(47,k=1);c(49,h(0));c(50,h(1
));c(51,h(2));c(52,h(3));}} 

http://www.ioccc.org/2001/ctk.hint:

This is a game based on an Apple ][ Print Shop Companion easter
egg named 'DRIVER', in which the goal is to drive as fast as
you can down a long twisty highway without running off the
road.  Use ',./', '[ ]', or 'bnm' to go left, straight, and
right respectively. Use '1234' to switch gears. 'q' quits. The
faster you go and the thinner the road is, the more points you
get. Most of the obfuscation is in the nonsensical if statements
among other things. It works best on the Linux console: you
get engine sound (!) and the * Lock keyboard lights tell you
what gear you're in (none lit=4th).  The 'q' argument (no
leading '-') will silence the sound. It won't work on a terminal
smaller than 80x24, but it works fine with more (try it in an
XTerm with the "Unreadable" font and the window maximized
vertically!).

展示一下你自己反混淆的进展。你肯定至少重新格式化和预处理了它,对吧?你对代码有什么具体的问题? - John Kugelman
2
@JohnKugelman:那并不是很有帮助。一个反混淆工具(似乎是问题所问)应该能够自行完成这项任务。 - bitmask
1个回答

21

第一步

使用:

sed -e'/#include/d' ctk.c | gcc -E - | sed -e's/;/;\n/g' -e's/}/}\n/g' -e '/^#/d' | indent

我能够生成以下输出,虽然不完美,但已经看起来更易读了很多:

char x[] = "((((((((((((((((((((((", w[] =
  "\b\b\b\b\b\b\b\b\b\b\b\b\b\b\b\b\b\b\b\b\b\b";
char r[] = { 92, 124, 47 }

, l[] =
{
2, 3, 1, 0}

;
char *T[] = { "  |", "  |", "%\\|/%", " %%%", "" }

;
char d = 1, p = 40, o = 40, k = 0, *a, y, z, g = -1, G, X, **P = &T[4], f = 0;
unsigned int s = 0;
void
u (int i)
{
  int n;
  printf ("\233;
%uH\233L%c\233;
%uH%c\233;
%uH%s\23322;
%uH@\23323;
%uH \n", *x - *w, r[d], *x + *w, r[d], X, *P, p += k, o);
  if (abs (p - x[21]) >= w[21])
    exit (0);
  if (g != G)
    {
      struct itimerval t = { 0, 0, 0, 0 }
      ;
      g += ((g < G) << 1) - 1;
      t.it_interval.tv_usec = t.it_value.tv_usec = 72000 / ((g >> 3) + 1);
      setitimer (0, &t, 0);
      f && printf ("\e[10;
%u]", g + 24);
    }
  f && putchar (7);
  s += (9 - w[21]) * ((g >> 3) + 1);
  o = p;
  a = x;
  z = *a;
  while (*++a)
    {
      y = *a;
      *a = z;
      z = y;
    }
  ;
  a = w;
  z = *a;
  while (*++a)
    {
      y = *a;
      *a = z;
      z = y;
    }
  ;
  (n = rand ()) & 255 || --*w || ++*w;
  if (!(**P && P++ || n & 7936))
    {
      while (abs ((X = rand () % 76) - *x + 2) - *w < 6);
      ++X;
      P = T;
    }
  (n = rand () & 31) < 3 && (d = n);
  !d && --*x <= *w && (++*x, ++d) || d == 2 && ++*x + *w > 79 && (--*x, --d);
  signal (i, u);
}

void
e ()
{
  signal (14, SIG_IGN);
  printf ("\e[0q\ecScore: %u\n", s);
  system ("stty echo -cbreak");
}

int main (int C, char **V)
{
  atexit (e);
  (C < 2 || *V[1] != 113)
    && (f = (C = *(int *) getenv ("TERM")) == (int) 0x756E696C
    || C == (int) 0x6C696E75);
  srand (getpid ());
  system ("stty -echo cbreak");
  G = 0 << 3;
  printf ("\e[%uq", l[0]);
  u (14);
  for (;;)
    switch (getchar ())
      {
      case 113:
    return 0;
      case 91:
      case 98:
      case 44:
    k = -1;
    continue;
      case 32:
      case 110:
      case 46:
    k = 0;
    continue;
      case 93:
      case 109:
      case 47:
    k = 1;
    continue;
      case 49:
    G = 0 << 3;
    printf ("\e[%uq", l[0]);
    continue;
      case 50:
    G = 1 << 3;
    printf ("\e[%uq", l[1]);
    continue;
      case 51:
    G = 2 << 3;
    printf ("\e[%uq", l[2]);
    continue;
      case 52:
    G = 3 << 3;
    printf ("\e[%uq", l[3]);
    continue;
      }
}

现在怎么办?

我认为自动化处理在这个阶段已经无法做更多的事情了,因为从现在开始,“更可读”或“不太可读”可能取决于读者的特定偏好。

可以执行的一步是从字符串中删除转义序列并将它们放在另一个地方。事实证明整个过程

char l[] = {2, 3, 1, 0}

该变量仅用于主循环中的转义序列,无其他用途:

printf ("\e[%uq", l[0]);

等等。查找它们的含义:

ESC [ 0 q: clear all LEDs
ESC [ 1 q: set Scroll Lock LED
ESC [ 2 q: set Num Lock LED
ESC [ 3 q: set Caps Lock LED

根据个人喜好,您可能想要将它们与更适合您的宏或函数调用交换,例如clear_all_LEDs等。

我强烈怀疑机器会认为这是一种简化。事实证明,整个主循环似乎只是在处理用户输入的按键,因此将数字转换为相应的字符可能有助于提高可读性,例如替换:

case 113:
  return 0;
case 91:
case 98:
case 44:
  k = -1;
// ...
case 49:
  G = 0 << 3;
  printf ("\e[%uq", l[0]);

使用类似以下的东西:

case 'q':
  return 0;
case '[':
case 'b':
case ',':
  k = -1;
// ...
case '1':
  G = 0 << 3;
  set_Num_Lock_LED ();

哦,顺便说一下,既然我们已经在这个话题上了,为什么不把这个相当奇怪的 G 的名称改为 gear 呢?我坚信自动化过程不会比将其更名为 butterfly 更好。也许甚至不是这样。

在美化名称的同时,这个单一引用了u的函数也是另一个可以考虑的选择:

u (14);

可能更有意义的名称是update。既然我们已经包含了< signal.h >,为什么不进一步解密代码,将14替换为SIGALRM,像这样:

upadate (SIGALRM);

正如您所看到的,“反混淆”在这里需要采取与之前完全相反的步骤。这次用宏替换扩展。机器如何尝试决定哪个更有用?

我们可能想要将裸数替换为其他内容的另一个位置是更新函数中的此位置:

f && putchar (7);

为什么不用\a替换7,因为最终结果相同。也许我们应该将裸的f替换成更有意义的东西。我反对使用butterfly,而更愿意称其为play_sound
if (play_sound)
   putchar ('\a');

可能更易读的版本是我们正在寻找的。当然,我们不应忘记在所有其他位置替换f。我们主要函数开头的那个一定要注意:

混乱不堪

int main (int C, char **V)
{
  atexit (e);
  (C < 2 || *V[1] != 113)
    && (f = (C = *(int *) getenv ("TERM")) == (int) 0x756E696C
    || C == (int) 0x6C696E75);

在愉快地将f重命名为play_sounde - 不,还没有butterfly,这次我更倾向于将其称为:end时,我们发现函数签名在命名约定方面似乎有些奇怪:argc而不是Cargv而不是V在这里似乎更加常规。因此,我们得到:

int main (int argc, char* argv[])
{
  atexit (end);
  (argc < 2 || *argv[1] != 113)
    && (playsound = (argc = *(int *) getenv ("TERM")) == (int) 0x756E696C
    || argc == (int) 0x6C696E75);

由于这还不够美观,我们向我们的标准专员咨询,他告诉我们可以相对轻松地进行替换。

(A || B) && (C)

使用

if (A || B) { C }

并且
E = (x=F)==H || x==I

使用

x = F; 
if (x==H || x==I) 
  A=1; 
else 
  A=0;` 

也许这应该是整个代码更易读的版本:

if (argc < 2 || *argv[1] != 'q') {
   argc = *(int*) getenv ("TERM");
   if (argc == (int) 0x756E69 || argc == (int) 0x6C696E75))
     play_sound = 1;
   /* skip the else brach here as play_sound is alredy initialized to 0 */
}

现在又有一个人告诉我们,根据所谓的“字节序”(endianness),如果将看起来奇怪的数字0x6C696E75和0x756E69存储在内存中,则(将原始字节值解释为ASCII代码时)这些数字看起来就像"linu""unil"。其中一种是一种体系结构类型上的"unil",而另一种是"linu",而在具有不同字节序的另一种体系结构上则完全相反。
更仔细地观察,实际上发生了以下情况:
  • 我们从getenv("TERM")获取一个指向字符串的指针,然后将其强制转换为指向int的指针,再进行解引用,从而导致以int形式存储在字符串位置处的位模式。
  • 接下来,我们将此值与在该特定位置存储"unil"或"linu"时执行相同操作所得到的值进行比较。
可能我们只想检查TERM环境变量是否设置为"linux",因此我们的简化版本可能要在此执行字符串比较。
另一方面,我们无法确定允许以"unil"开头的终端播放声音是否是此软件的特殊功能,因此我决定最好保持不变。
现在怎么办?
在重命名和重新编码变量名和值时,这些奇怪的字符数组可能是我们的下一个受害者。以下混乱看起来不太好:
char x[] = "((((((((((((((((((((((", w[] =
  "\b\b\b\b\b\b\b\b\b\b\b\b\b\b\b\b\b\b\b\b\b\b";
char r[] = { 92, 124, 47 };

也许它们可以被改变为:
char x_offset[] = {
  40, 40, 40, 40, 40, 40, 40, 40, 40, 40,
  40, 40, 40, 40, 40, 40, 40, 40, 40, 40,
  40, 40, 0 };

char width[] = {
  8, 8, 8, 8, 8, 8, 8, 8, 8, 8,
  8, 8, 8, 8, 8, 8, 8, 8, 8, 8,
  8, 8, 0 };

const char border[] = "\\|/";

如您所见,我只是选择了将描述值的方式从字符串常量x切换为数组中写下的x,因为这样存储在此处的值的目的对我来说似乎更清晰一些。
另一方面,我改变了写下r的方式,正好相反,因为这样对我来说也更清晰明了。
在追踪所有对x、w和r的引用时,可以利用这段时间将p和o重命名为-pos和old_pos(再次抱歉没有butterfly),同时将s重命名为score。
例如更改:
  s += (9 - w[21]) * ((g >> 3) + 1);
  o = p;
  a = x;
  z = *a;
  while (*++a)
    {
      y = *a;
      *a = z;
      z = y;
    }
  ;
  a = w;
  z = *a;
  while (*++a)
    {
      y = *a;
      *a = z;
      z = y;
    }
  ;

to:

  /* update score */
  score += (9 - width[NEXT_LINE]) * ((g >> 3) + 1);
  old_pos = pos;

  /* shift x_offset */
  a = x_offset;
  z = *a;
  while (*++a) {
    y = *a;
    *a = z;
    z = y;
  };

  /* shift width */
  a = width;
  z = *a;
  while (*++a) {
    y = *a;
    *a = z;
    z = y;
  };

除了将它转换为其他类型的循环之外,对于这两个移位函数,几乎没有太多的美化可能性,因此添加适当的注释可能是你能做的最大限度。删除魔数 21 可能是另一个想法,在此处使用 NEXT_LINE 似乎并不是最糟糕的选择。
标记为单个字符的变量 g 仍然看起来不太好。但通过将其重命名为诸如 update_interval 的内容,还有机会消除另一个奇怪的终端转义序列:
 if (g != G)
    {
      struct itimerval t = { 0, 0, 0, 0 }
      ;
      g += ((g < G) << 1) - 1;
      t.it_interval.tv_usec = t.it_value.tv_usec = 72000 / ((g >> 3) + 1);
      setitimer (0, &t, 0);
      f && printf ("\e[10;
%u]", g + 24);
    }

也许看起来比下面那个更加混乱一些:
  /* update simulation speed */
  if (update_interval != gear) {
    struct itimerval t = { 0, 0, 0, 0 }  ;
      update_interval += ((update_interval < gear) << 1) - 1;
      t.it_interval.tv_usec = t.it_value.tv_usec = 72000 / ((update_interval >> 3) + 1);
      setitimer (0, &t, 0);
      if (play_sound)
        change_bell_frequency (update_interval + 24);
  }

最后修复

虽然现在代码看起来更加易读了,但还是有一些不好的部分:

!d && --*x <= *w && (++*x, ++d) || d == 2 && ++*x + *w > 79 && (--*x, --d);

选择另一个(希望更有意义的)名称来代替d,并将运算符优先级分解,你可能得到如下内容:

  if (curve == CURVE_LEFT) {
    --*x_offset;
    if (*x_offset < *width) {
       ++*x_offset;
       curve = CURVE_NONE;
    }
  }
  else if (curve == CURVE_RIGHT) {
    ++*x_offset;
    if (*x_offset + *width > 79) {
      --*x_offsett;
      curve = CURVE_NONE;
    }
  } 

代替为所有这些CURVE_...添加适当的宏。

现在还有那些XPT名称挂在那里,这些名称也可能会改变。因为它使其目的在代码中更加清晰可见,所以我决定翻转T的行顺序,并将其重命名为tree,这肯定意味着计算也必须修复。总之,从以下内容开始:

char *T[] = { "  |", "  |", "%\\|/%", " %%%", "" };
char X, **P = &T[4];

// ...

  if (!(**P && P++ || n & 7936))
    {
      while (abs ((X = rand () % 76) - *x + 2) - *w < 6);
      ++X;
      P = T;
    }

转换成如下形式:

char *tree[] = {
  "",
  " %%%",
  "%\\|/%",
  "  |",
  "  |",
};

char **tree_line = tree;
char tree_position;

// ...

  /* update tree line pointer */
  if (!(**tree_line && tree_line-- || n & 7936)) {
    /* find the right spot to grow */
    while (abs ((tree_position = rand () % 76) - *x_offset + 2) - *width < 6)
      ;
    ++tree_position;
    tree_line = &tree[4];
  }

最精华的内容要留到最后

虽然代码看起来已经比较漂亮了,但还有一个部分需要完善。这个部分是负责所有输出的代码行:

 printf ("\233;%uH\233L%c\233;%uH%c\233;%uH%s\23322;%uH@\23323;%uH \n",
      *x - *w, r[d], *x + *w, r[d], X, *P, p += k, o); 

除了看起来很难读之外,它甚至对于计算机来说也是过于模糊的,因此无法产生任何可用的结果。我尝试在其他终端仿真器中运行许多不同的事物,更改终端设置并来回切换语言环境,但都没有成功。
所以除了这种混淆似乎更加完美,因为它甚至似乎会让我的计算机感到困惑,我仍然无法确定作者打算使用哪种技巧。
八进制代码\233具有与转义字符(\033)相同的位模式,并额外设置第8个位,这可能与预期的效果在某种程度上相关。不幸的是,就像我已经说过的那样,它对我没有用。
幸运的是,转义序列仍然很容易猜测,因此我想出了以下替代方案: pos += move_x,
  /* draw street */
  printf ("\e[1;%uH" "\e[L" "%c"
          "\e[1;%uH" "%c",
          *x_offset - *width, border[curve],
          *x_offset + *width, border[curve]);
  /* draw tree */
  printf ("\e[1;%uH" "%s",
          tree_position, *tree_line);

  /* redraw car */
  printf ("\e[22;%uH" "@"
          "\e[23;%uH" " " "\n",
          pos,
          old_pos);  

将绘图分解为单独的部分(希望)使它们更易读。实际的线和前一行仍然像原始版本中一样是硬编码的。也许从下面所示的位置提取它们甚至会提高可读性:

  /* draw street */
  printf ("\e[1;%uH" "\e[L" "%c"
          "\e[1;%uH" "%c",
          *x_offset - *width, border[curve],
          *x_offset + *width, border[curve]);
  /* draw tree */
  printf ("\e[1;%uH" "%s",
          tree_position, *tree_line);

  /* redraw car */
  printf ("\e[%u;%uH" "@"
          "\e[%u;%uH" " " "\n",
          NEXT_LINE +1, pos,
          NEXT_LINE +2, old_pos);

这最终让我得到了第一个可用版本,然后我进行了大量的“测试”。虽然可能不是100%的前沿技术,但它仍然似乎非常具有吸引力。

最后的话

这里是我最终得到的未混淆版本。正如您所看到的,我没有实现LED设置功能和清除屏幕功能,但应该很容易找到散布在混淆版本中的所需转义序列。事实上,我已经在本文中提到了LED序列。用于清除屏幕的序列是"\ e [0q"。祝愉快地黑客。

#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <sys/time.h>
#include <signal.h>

#define NEXT_LINE 21

#define CURVE_LEFT 0
#define CURVE_NONE 1
#define CURVE_RIGHT 2

char x_offset[] = {
  40, 40, 40, 40, 40, 40, 40, 40, 40, 40,
  40, 40, 40, 40, 40, 40, 40, 40, 40, 40,
  40, 40, 0 };

char width[] = {
  8, 8, 8, 8, 8, 8, 8, 8, 8, 8,
  8, 8, 8, 8, 8, 8, 8, 8, 8, 8,
  8, 8, 0 };

const char border[] = "\\|/";

void change_bell_frequency () {}
void clear_screen () {}
void clear_all_LEDs () {}
void set_Num_Lock_LED () {}
void set_Scroll_lock_LED () {}
void set_Caps_Lock_LED () {}



char *tree[] = {
  "",
  " %%%",
  "%\\|/%",
  "  |",
  "  |",
};


char **tree_line = tree;
char tree_position;

char curve = CURVE_NONE;
char *a, y, z;

char move_x = 0;
char update_interval = -1;

char pos = 40;
char old_pos = 40;

char play_sound = 0;
char gear;

unsigned int score = 0;

void move (char x, char y) {
  printf ("\e[%u;%uH", x, y);
}

void insert () {
  printf ("\e[L");
}

void update (int i) {
  int n;

  pos += move_x,

  /* draw street */
  printf ("\e[1;%uH" "\e[L" "%c"
          "\e[1;%uH" "%c",
          *x_offset - *width, border[curve],
          *x_offset + *width, border[curve]);
  /* draw tree */
  printf ("\e[1;%uH" "%s",
          tree_position, *tree_line);

  /* redraw car */
  printf ("\e[%u;%uH" "@"
          "\e[%u;%uH" " " "\n",
          NEXT_LINE + 1, pos,
          NEXT_LINE +2, old_pos);

  /* did we leave the road ? */
  if (abs (pos - x_offset[NEXT_LINE]) >= width[NEXT_LINE])
    exit (0);

  /* update simulation speed */
  if (update_interval != gear) {
    struct itimerval t = { 0, 0, 0, 0 }  ;
      update_interval += ((update_interval < gear) << 1) - 1;
      t.it_interval.tv_usec = t.it_value.tv_usec = 72000 / ((update_interval >> 3) + 1);
      setitimer (0, &t, 0);
      if (play_sound)
        change_bell_frequency (update_interval + 24);
  }

  /* play sound */
  if (play_sound)
    putchar ('\a');

  /* update score */
  score += (9 - width[NEXT_LINE]) * ((update_interval >> 3) + 1);
  old_pos = pos;

  /* shift x_offset */
  a = x_offset;
  z = *a;
  while (*++a) {
    y = *a;
    *a = z;
    z = y;
  };

  /* shift width */
  a = width;
  z = *a;
  while (*++a) {
    y = *a;
    *a = z;
    z = y;
  };

  /* generate new road */
  n = rand ();

  if (!(n & 255) && *width > 1)
    --*width;

  /* set tree line pointer */
  if (!(**tree_line && tree_line-- || n & 7936)) {
    /* find the right spot to grow */
    while (abs ((tree_position = rand () % 76) - *x_offset + 2) - *width < 6)
      ;
    ++tree_position;
    tree_line = &tree[4];
  }

  /* new offset */
  n = rand () & 31;
  if (n < 3)
    curve = n;

  if (curve == CURVE_LEFT) {
    --*x_offset;
    if (*x_offset <= *width) {
      ++*x_offset;
      curve = CURVE_NONE;
    }
  }
  else if (curve == CURVE_RIGHT) {
    ++*x_offset;
    if (*x_offset + *width > 79) {
      --*x_offset;
      curve = CURVE_NONE;
    }
  }

  signal (SIGALRM, update);
}


void end () {
  signal (SIGALRM, SIG_IGN);
  clear_all_LEDs ();
  clear_screen ();
  printf ("Score: %u\n", score);
  system ("stty echo -cbreak");
}


int main (int argc, char **argv) {
  atexit (end);

  if (argc < 2 || *argv[1] != 'q') {
    argc = *(int*) getenv ("TERM");
    if (argc == (int) 0x6C696E75 || argc == (int) 0x756E696C)
      play_sound = 1;
  }

  srand (getpid ());
  system ("stty -echo cbreak");
  gear = 0 << 3;

  clear_all_LEDs ();
  update (14);
  for (;;)
    switch (getchar ())
      {
        case 'q':
          return 0;
        case '[':
        case 'b':
        case ',':
          move_x = -1;
          continue;
        case ' ':
        case 'n':
        case '.':
          move_x = 0;
          continue;
        case ']':
        case 'm':
        case '/':
          move_x = 1;
          continue;
        case '1':
          gear = 0 << 3;
          set_Num_Lock_LED ();
          continue;
        case '2':
          gear = 1 << 3;
          set_Caps_Lock_LED ();
          continue;
        case '3':
          gear = 2 << 3;
          set_Scroll_lock_LED ();
          continue;
        case '4':
          gear = 3 << 3;
          clear_all_LEDs ();
          continue;
      }
}

回答这种问题会削弱 IOCCC 条目的全部意义。 它们不是为了让你解码。 你应该自己想出来。 - Randy Howard
9
“耸肩”不知道 - 我只是觉得它可以为整个乐趣做广告,甚至可能会激起更多的兴趣。 - mikyra
@mikyra:太棒了,这才是真正的黑客技术。干得好! - alecov
FYI:字符\233(0x9b)是控制序列引导符。在大多数终端中,它相当于\e[ - nneonneo

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接