一种输入回显限制的CLI编写思路

阅读量    58582 |

分享到: QQ空间 新浪微博 微信 QQ facebook twitter

 

0x00 写在前面

前两天对某厂商的路由器进行测试(已获授权)时,发现目标设备的SSH/Telnet连入后会被强制进入一个CLI中。此CLI会对用户的输入进行限制,当用户输入的命令不是预置命令时将会无法回车提交。于是对此种CLI的实现机制产生了兴趣,本文将说明一种可行的安全CLI的编写思路。

 

0x01 实现思路

首先最直观的想法,我们无法使用空格进行提交,说明回车符(Space)被程序忽略了。或者说,我们的输入并没有被预期的打印,那么按这个思路来想,可以很容易的想到Linux下的密码输入有同样的特点。

在输入密码时,我们的输入能被后端捕获,但是不会显示在屏幕上,同时^C之类的控制指令也会同时失效。

 

0x02 库函数实现

那么Linux是否提供了相应的函数以供我们使用呢?答案是肯定的,那就是getpassword函数。

man手册中,此函数的函数原型如下:

#include <unistd.h>
char *getpass(const char *prompt);

函数描述为:

DESCRIPTION         top
       This function is obsolete.  Do not use it.  If you want to read
       input without terminal echoing enabled, see the description of
       the ECHO flag in termios(3).

       The getpass() function opens /dev/tty (the controlling terminal
       of the process), outputs the string prompt, turns off echoing,
       reads one line (the "password"), restores the terminal state and
       closes /dev/tty again.

显然,此函数是一个过时函数了,但是我们或许可以从它的实现中找到我们真正感兴趣的东西。(函数定义位于glibc/misc/getpass.c

/* Copyright (C) 1992-2019 Free Software Foundation, Inc.
   This file is part of the GNU C Library.

   The GNU C Library is free software; you can redistribute it and/or
   modify it under the terms of the GNU Lesser General Public
   License as published by the Free Software Foundation; either
   version 2.1 of the License, or (at your option) any later version.

   The GNU C Library is distributed in the hope that it will be useful,
   but WITHOUT ANY WARRANTY; without even the implied warranty of
   MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
   Lesser General Public License for more details.

   You should have received a copy of the GNU Lesser General Public
   License along with the GNU C Library; if not, see
   <http://www.gnu.org/licenses/>.  */

#include <stdio.h>
#include <stdio_ext.h>
#include <string.h>        /* For string function builtin redirect.  */
#include <termios.h>
#include <unistd.h>

#include <wchar.h>
#define flockfile(s) _IO_flockfile (s)
#define funlockfile(s) _IO_funlockfile (s)
#include <libc-lock.h>

/* It is desirable to use this bit on systems that have it.
   The only bit of terminal state we want to twiddle is echoing, which is
   done in software; there is no need to change the state of the terminal
   hardware.  */

#ifndef TCSASOFT
#define TCSASOFT 0
#endif

static void call_fclose (void *arg)
{
  if (arg != NULL)
    fclose (arg);
}

char * getpass (const char *prompt)
{
  FILE *in, *out;
  struct termios s, t;
  int tty_changed;
  static char *buf;
  static size_t bufsize;
  ssize_t nread;

  /* Try to write to and read from the terminal if we can.
     If we can't open the terminal, use stderr and stdin.  */

  in = fopen ("/dev/tty", "w+ce");
  if (in == NULL)
    {
      in = stdin;
      out = stderr;
    }
  else
    {
      /* We do the locking ourselves.  */
      __fsetlocking (in, FSETLOCKING_BYCALLER);

      out = in;
    }

  /* Make sure the stream we opened is closed even if the thread is
     canceled.  */
  __libc_cleanup_push (call_fclose, in == out ? in : NULL);

  flockfile (out);

  /* Turn echoing off if it is on now.  */

  if (__tcgetattr (fileno (in), &t) == 0)
    {
      /* Save the old one. */
      s = t;
      /* Tricky, tricky. */
      t.c_lflag &= ~(ECHO|ISIG);
      tty_changed = (tcsetattr (fileno (in), TCSAFLUSH|TCSASOFT, &t) == 0);
    }
  else
    tty_changed = 0;

  /* Write the prompt.  */
  __fxprintf (out, "%s", prompt);
  __fflush_unlocked (out);

  /* Read the password.  */
  nread = __getline (&buf, &bufsize, in);
  if (buf != NULL)
    {
      if (nread < 0)
    buf[0] = '\0';
      else if (buf[nread - 1] == '\n')
    {
      /* Remove the newline.  */
      buf[nread - 1] = '\0';
      if (tty_changed)
        /* Write the newline that was not echoed.  */
        __fxprintf (out, "\n");
    }
    }

  /* Restore the original setting.  */
  if (tty_changed)
    (void) tcsetattr (fileno (in), TCSAFLUSH|TCSASOFT, &s);

  funlockfile (out);

  __libc_cleanup_pop (0);

  if (in != stdin)
    /* We opened the terminal; now close it.  */
    fclose (in);

  return buf;
}

可以看到,此函数有会在入口尝试启动一个新的tty用于之后的操作,若没有多余的tty,将会尝试直接使用stdinstderr作为输入输出流,随后它调用了tcsetattrstdin进行了设置,然后就进入了文本读取逻辑,最后同样调用了tcsetattr进行了设置恢复从而使stdin恢复了正常,那么看起来最核心的函数就是tcsetattr了。

 

0x03 核心函数

我们来看看tcsetattr函数的实现,在man手册中,它的函数原型如下:

#include <termios.h>

int tcsetattr(int fildes, int optional_actions,const struct termios *termios_p);

tcsetattr()函数使用termios_p参数指向的termios结构体对fildes参数指定的文件描述符所对应的处于开启状态的文件流进行设置。optional_actions用于指定此配置生效的时机,定义如下:

  • TCSANOW:此配置应立即生效。
  • TCSADRAIN:在所有写入fildes的输出信息传输后生效,若更改会影响输出的参数时应使用此配置。
  • TCSAFLUSH:在所有写入fildes的输出信息传输后生效,并且在更改之前应丢弃迄今为止接收到但未读取的所有输入。

那么接下来来看看termios结构体:

tcflag_t c_iflag;      /* input modes */
tcflag_t c_oflag;      /* output modes */
tcflag_t c_cflag;      /* control modes */
tcflag_t c_lflag;      /* local modes */
cc_t     c_cc[NCCS];   /* special characters */

man手册所述,没有对termios结构体的标准定义,但是一个termios结构体至少要求包含以上成员,而这恰好也是我们要用的成员变量。

输入模式控制c_iflag——控制终端输入方式

键值 意义
IGNBRK 忽略BREAK键输入
BRKINT 如果同时设置了IGNBRKBREAK键的输入将被忽略,如果设置了BRKINT但是没有设置IGNBRKBREAK键将导致输入和输出队列被刷新。如果终端是前台进程组的控制终端,这会导致此终端向前台进程组发送SIGINT中断信号。 当 IGNBRKBRKINT均未设置时,BREAK键的输入将会被读取为空字节 (\0),除非设置了PARMRK,此时BREAK键的输入将会被读取为序列\377 \0 \0
IGNPAR 忽略帧错误和奇偶校验错误
PARMRK 如果设置了该位,则在输入传递给程序时会标记具有奇偶校验错误或帧错误的输入字节。 仅当INPCK置位且IGNPAR未置位时,该位才有意义。 标记错误字节的方式是使用两个前面的字节,\377\0。 因此,对于从终端接收到的一个错误字节,该程序实际上读取了三个字节。 如果有效字节的值为\377,并且未设置ISTRIP,则程序可能会将其与标记奇偶校验错误的前缀混淆。 因此,这种情况下有效字节\377作为两个字节\377 \377传递给程序。如果IGNPARPARMRK均未设置,则将具有奇偶校验错误或帧错误的字符读取为\0
INPCK 对输入内容启用奇偶校验
ISTRIP 去除字符的第8个比特
INLCR 将输入的NL(换行)转换成CR(回车)
IGNCR 忽略输入的回车
ICRNL 将输入的回车转化成换行(如果IGNCR未设置的情况下)
IUCLC 将输入的大写字符转换成小写字符(非POSIX)
IXON 允许输入时对XON/XOFF流进行控制
IXANY (XSI扩展)输入任何字符都将重新启动停止的输出(默认是只允许START字符重新启动输出)
IXOFF 允许输入时对XON/XOFF流进行控制(笔者注:此处可能是手册有误,推测应为关闭XON/XOFF流控)
IMAXBEL 当输入队列已满时响铃,Linux没有实现这个位,它会视为此标志永远被设置(非POSIX)
IUTF8 将输入视为UTF8,这将允许在cooked模式下正确执行字符擦除

·Break键是电脑键盘上的一个键。Break键起源于19世纪的电报。在DOS时代,Pause/Break是常用键之一,但是近年来该键的使用频率逐年减少。在某些较旧的程序中,按这个键会使程序暂停,若同时按Ctrl,会使程序停止而无法执行。

因为Break可以中断程序,所以Break键也被称为Pause键。

输出模式控制c_oflag——控制终端输出方式

键值 意义
OPOST 启用先处理后输出机制
OLCUC 在输出时将小写字符映射为大写(非POSIX)
ONLCR (XSI扩展)在输出时将NL(换行) 映射到CR-NL(回车-换行)
OCRNL 将输入的CR(回车)转换成NL(换行)
ONOCR 不在第0列输出CR(回车)
ONLRET 不输出CR(回车)
OFILL 发送填充字符以延迟终端输出,而不是使用定时延迟
OFDEL 将填充字符设置为ASCII DEL(0177),如果未设置此标志,则填充字符为ASCII NUL(\0)(未在Linux上实现)
NLDLY 换行输出延时,可以取NL0(不延迟)或NL1(延迟0.1s) [需要_BSD_SOURCE_SVID_SOURCE_XOPEN_SOURCE]
CRDLY 回车输出延时,可以取CR0CR1CR2CR3[需要_BSD_SOURCE_SVID_SOURCE_XOPEN_SOURCE]
TABDLY 水平制表符输出延时,可以取TAB0TAB1TAB2TAB3(或XTABS,但请参阅BUGS部分)。TAB3XTABS代表将制表符扩展为空格(每八列停止输出一次制表符)[需要_BSD_SOURCE_SVID_SOURCE_XOPEN_SOURCE]
BSDLY Backspace延迟输出延时,可以取BS0BS1(从未实现)[需要_BSD_SOURCE_SVID_SOURCE_XOPEN_SOURCE]
VTDLY 垂直制表符输出延时,可以取VT0VT1
FFDLY 换页输出延时,可以取FF0FF1 [需要_BSD_SOURCE_SVID_SOURCE_XOPEN_SOURCE]

终端控制c_cflag——指定终端硬件控制信息

键值 意义
CBAUD (非POSIX)设置波特率掩码(4+1位)[需要_BSD_SOURCE_SVID_SOURCE]
CBAUDEX (非POSIX)设置额外的波特率掩码(1位),包含在CBAUD中(POSIX定义波特率存储在termios结构中,没有指定精确的位置,并提供cfgetispeed()cfsetispeed()来获取它。一些系统使用CBAUDc_cflag中选择的位,其他系统使用单独的字段,例如sg_ispeedsg_ospeed)[需要_BSD_SOURCE_SVID_SOURCE]
CSIZE 设置字符长度掩码,取值范围为CS5CS6CS7CS8
CSTOPB 设置两个停止位,而不是一个
CREAD 启用接收器
PARENB 启用输出奇偶校验生成和输入奇偶校验
PARODD 如果设置,则输入和输出的奇偶校验为奇校验,否则使用偶校验
HUPCL 在最后一个进程关闭设备(挂起)后,降低调制解调器控制线
CLOCAL 忽略调制解调器控制线
LOBLK (非POSIX)阻止来自非当前shell层的输出。此选项供shl(shell层)使用(未在Linux上实现)
CIBAUD (非POSIX)输入速度掩码。CIBAUD位的值与CBAUD位的值相同,向左移位IBSHIFT位 [需要_BSD_SOURCE_SVID_SOURCE](未在Linux上实现)
CMSPAR (非POSIX)启用stick(标记/空格)奇偶校验(某些串行设备支持):如果设置了PARODD,则奇偶校验位始终为1,如果 PARODD未设置,则奇偶校验位始终为0[需要_BSD_SOURCE_SVID_SOURCE]
CRTSCTS (非POSIX)启用RTS/CTS(硬件)流控制 [需要_BSD_SOURCE_SVID_SOURCE]

本地模式c_lflag——控制终端编辑功能

键值 意义
ISIG 当接收到INTRQUITSUSPDSUSP中的任何字符时,生成相应的信号
ICANON 启用规范编辑模式(以\n分割输入)
XCASE (非POSIX,未在Linux上实现)如果还设置了ICANON,则终端仅是大写的。否则,输入将转换为小写,但以\开头的字符除外。在输出时,大写字符以\开头,小写字符转换为大写 [需要_BSD_SOURCE_SVID_SOURCE_XOPEN_SOURCE]
ECHO 回显输入的字符
ECHOE 如果还设置了ICANONERASE字符会擦除前面的输入字符,WERASE会擦除前面的单词
ECHOK 如果还设置了ICANONKILL字符将擦除当前行
ECHONL 如果还设置了ICANON,即使未设置ECHO,也会回显NL字符
ECHOCTL (非POSIX)如果还设置了ECHO,除TABNLSTARTSTOP之外的终端特殊字符将作为^X回显,其中X是特殊字符的ASCII码值+ 0x40的字符。例如,字符0x08 (BS)回显为^H[需要_BSD_SOURCE_SVID_SOURCE]
ECHOPRT (非POSIX)如果还设置了ICANONECHO,则在擦除字符时将其打印 [需要_BSD_SOURCE_SVID_SOURCE]
ECHOKE (非POSIX)如果还设置了ICANON,则按照ECHOEECHOPRT的规定,通过擦除行上的每个字符来回显KILL[需要_BSD_SOURCE_SVID_SOURCE]
DEFECHO (非POSIX,未在Linux上实现)仅在进程读取时回显
FLUSHO (非POSIX,未在Linux上实现)刷新输出流。通过键入DISCARD字符来切换此标志状态 [需要_BSD_SOURCE_SVID_SOURCE]
NOFLSH 在为INTQUITSUSP字符生成信号时不再刷新输入和输出队列
TOSTOP SIGTTOU信号发送到尝试写入其控制终端的后台进程的进程组
PENDIN (非POSIX,未在Linux上实现)在读取下一个字符时重新打印输入队列中的所有字符。(bash(1)以这种方式处理预输入) [需要_BSD_SOURCE_SVID_SOURCE]
IEXTEN 启用实现定义的输入处理。必须启用此标志以及ICANON才能对特殊字符EOL2LNEXTREPRINTWERASE进行解释,并使 IUCLC 标志有效

 

0x04 实现过程

最初的版本

那么,有了核心函数,接下来就可以着手编写我们的CLI了。首先,最直接的思路,我们既然希望控制用户输入的字符,那么我们可以捕获用户键入的每一个字符,将其获取至CLI,然后由CLI进行初步处理后打印。那么以上思路就要解决以下两个问题:

  • 通常情况下,当用户敲击回车后所输入的内容才会被提交至程序内的内容接收函数中。那么如何逐字符获取呢?
  • 用户输入内容时,默认是回显的,如何不回显呢?

那么注意到本地模式控制中的ECHOICANON配置项显然可以达成我们的要求,前者可以关闭用户输入的回显,后者则可以捕获即时敲击的内容。

于是很容易写出代码如下:

#include <stdio.h>
#include <string.h>    
#include <termios.h>
#include <unistd.h>

void init(){
    setbuf(stdin, NULL);
    setbuf(stdout, NULL);
    setbuf(stderr, NULL);
}

int main(){
    FILE *in;
    struct termios s, t;
    int tty_changed;
    char command[100000];
    memset(command,0,100000);
    char *commandPoint = command;

    init();
    in = stdin;
    if(tcgetattr (fileno(in), &t) == 0)
    {
        s = t;
        t.c_lflag &= ~(ECHO|ICANON);
        tty_changed = (tcsetattr (fileno (in), TCSANOW, &t) == 0);
    }
    else
        tty_changed = 0;
    while (1)
    {
        printf("蓝晶@汪汪系统:~$ ");
        char inputChar;
        read(fileno(in),&inputChar,1);
        while (inputChar)
        {
            if(inputChar == '\n'){
                break;
            }
            *(commandPoint++) = inputChar;
            write(1,&inputChar,1);
            read(fileno(in),&inputChar,1);
        }
        printf("\n");
        printf("%s\n",command);
        memset(command,0,100000);
        commandPoint = command;
    }
    if (tty_changed)
        (void) tcsetattr (fileno (in), TCSANOW, &s);
    return 0;
}

这里可以看到,我们为了最大程度的防止stdin文件流被破坏,我们选择与getpass()函数中一样,先使用tcgetattr读取stdin的配置,然后再进行ECHOICANON标志位的配置。随后我们定义了一个命令字符串用于存放用户输入的指令,同时定义了一个指针指向其开头,当获取到用户输入时,依次将其存入此数组,并在读取到回车(\n)后将其打印,随后将命令字符串清空,将指针置回字符串起始。当然在整个程序最后别忘了将终端的设置恢复,否则易导致终端异常的发生。

解决退格的版本(?)

不过我们测试其实可以发现,最初的版本中实际上是无法使用退格的,这是因为,在非标准输入模式下,我们需要对退格进行单独的处理,BackSpace对应的并不是\b,查阅ASCII码表可以发现,backspace对应的是127,那么我们只要新增对127的处理即可。

#include <stdio.h>
#include <string.h>    
#include <termios.h>
#include <unistd.h>

void init(){
    setbuf(stdin, NULL);
    setbuf(stdout, NULL);
    setbuf(stderr, NULL);
}

int main(){
    FILE *in;
    struct termios s, t;
    int tty_changed;
    char command[100000];
    memset(command,0,100000);
    char *commandPoint = command;

    init();
    in = stdin;
    if(tcgetattr (fileno(in), &t) == 0)
    {
        s = t;
        t.c_lflag &= ~(ECHO|ICANON);
        tty_changed = (tcsetattr (fileno (in), TCSANOW, &t) == 0);
    }
    else
        tty_changed = 0;
    while (1)
    {
        printf("蓝晶@汪汪系统:~$ ");
        char inputChar;
        read(fileno(in),&inputChar,1);
        while (inputChar)
        {
            if(inputChar == '\n'){
                break;
            }else if(inputChar == 127){
                if(strlen(command) > 0){
                    *(--commandPoint) = 0;
                    printf("\b \b");
                }
            }
            *(commandPoint++) = inputChar;
            write(1,&inputChar,1);
            read(fileno(in),&inputChar,1);
        }
        printf("\n");
        printf("%s\n",command);
        memset(command,0,100000);
        commandPoint = command;
    }
    if (tty_changed)
        (void) tcsetattr (fileno (in), TCSANOW, &s);
    return 0;
}

注意此处我们并不是简单的输出\b,而是需要输出\b \b因为\b的意义仅仅是将光标前移一位。因此我们的处理逻辑是光标前移之后输出一个空格覆盖需要删除的内容,然后再退回输入点

解决退格的版本

根据上面的截图我们其实可以看出来事实上简单地进行退格信号处理是存在问题的,这不仅将导致指针越界,还会导致不应该被删除的内容被删除,因此我们需要对信号的处理加以限制。

#include <stdio.h>
#include <string.h>    
#include <termios.h>
#include <unistd.h>

void init(){
    setbuf(stdin, NULL);
    setbuf(stdout, NULL);
    setbuf(stderr, NULL);
}

int main(){
    FILE *in;
    struct termios s, t;
    int tty_changed;
    char command[100000];
    memset(command,0,100000);
    char *commandPoint = command;

    init();
    in = stdin;
    if(tcgetattr (fileno(in), &t) == 0)
    {
        s = t;
        t.c_lflag &= ~(ECHO|ICANON);
        tty_changed = (tcsetattr (fileno (in), TCSANOW, &t) == 0);
    }
    else
        tty_changed = 0;
    while (1)
    {
        printf("蓝晶@汪汪系统:~$ ");
        char inputChar;
        read(fileno(in),&inputChar,1);
        while (inputChar)
        {
            if(inputChar == '\n'){
                break;
            }else if(inputChar == 127){
                if(strlen(command) > 0){
                    *(--commandPoint) = 0;
                    printf("\b \b");
                }
            }else{
                *(commandPoint++) = inputChar;
                write(1,&inputChar,1);
            }
            read(fileno(in),&inputChar,1);
        }
        printf("\n");
        printf("%s\n",command);
        memset(command,0,100000);
        commandPoint = command;
    }
    if (tty_changed)
        (void) tcsetattr (fileno (in), TCSANOW, &s);
    return 0;
}

此时,我们退格将不会在产生超预期的行为

加一点细节的版本

最后~我们加亿点细节,得到最终代码:

#include <stdio.h>
#include <string.h>    
#include <termios.h>
#include <unistd.h>
#define WHITELISTCOMMANDNUM 4

static void call_fclose (void *arg)
{
  if (arg != NULL)
    fclose(arg);
}

void init(){
    setbuf(stdin, NULL);
    setbuf(stdout, NULL);
    setbuf(stderr, NULL);
}

int main(){
    FILE *in, *out;
    struct termios s, t;
    int tty_changed;
    char command[100000];
    memset(command,0,100000);
    char *commandPoint = command;
    char *whitelist[WHITELISTCOMMANDNUM] = {"exit","ver","ping","pig"};

    init();
    in = stdin;
    if(tcgetattr (fileno(in), &t) == 0)
    {
        s = t;
        t.c_lflag &= ~(ECHO|ICANON);
        tty_changed = (tcsetattr (fileno (in), TCSANOW, &t) == 0);
    }
    else
        tty_changed = 0;
    while (1)
    {
        printf("蓝晶@汪汪系统:~$ ");
        printf("%s",command);
        char inputChar;
        read(fileno(in),&inputChar,1);
        int commandStatus = 0;
        char maybecommand[1000000];
        int maybeCommandCount = 0;
        memset(maybecommand,0,1000000);
        while (inputChar)
        {
            if(inputChar == '\n'){
                memset(maybecommand,0,1000000);
                int maybecount = 0;
                commandStatus = 0;
                for(int whitelistidx = 0;whitelistidx < WHITELISTCOMMANDNUM;whitelistidx++){
                    if(strlen(command) == strlen(whitelist[whitelistidx]) && !strncmp(command,whitelist[whitelistidx],strlen(whitelist[whitelistidx]))){
                        commandStatus = 2;
                        break;
                    }else if(strstr(whitelist[whitelistidx],command) == whitelist[whitelistidx]){
                        commandStatus = 1;
                        maybeCommandCount++;
                        strcat(maybecommand,whitelist[whitelistidx]);
                        strcat(maybecommand,"\t");
                    }
                }
                if(commandStatus == 1 && maybeCommandCount == 1) {
                    memset(command,0,100000);
                    strncpy(command,maybecommand,strlen(maybecommand)-1);
                    printf("%s" , commandPoint);
                    commandStatus = 2;
                }
                if(commandStatus != 0) break;
            }else if(inputChar == 127){
                if(strlen(command) > 0){
                    *(--commandPoint) = 0;
                    printf("\b \b");
                }
            }else{
                *(commandPoint++) = inputChar;
                write(1,&inputChar,1);
            }
            read(fileno(in),&inputChar,1);
        }
        printf("\n");
        if(commandStatus == 1){
            printf("%s\n",maybecommand);
        }else if(commandStatus == 2){
            if(!strcmp(command,"ver")){
                printf("自豪的由汪汪的小蓝蓝开发!是安全的兽人Shell!欢迎一起成为Furry!Version:0.1beta\n");
            }else if(!strcmp(command,"exit")){
                printf("Bye!\n");
                break;
            }else{
                printf("%s\n",command);
            }
            memset(command,0,100000);
            commandPoint = command;
        }
    }
    if (tty_changed)
        (void) tcsetattr (fileno (in), TCSANOW, &s);
    return 0;
}

添加的细节代码均为基础的C语言基础机制代码,此处不再解释,请读者自行识读~

 

0x05 后记

后面我会出一些基于此CLI(当然不是beta版本的CLI嘿嘿嘿)的CTF题目,同时也会在CLI中加入用户管理、Tab补全、敏感字符控制、密码学等元素,创造一个与双边协议系列类似的系列题目~敬请期待~

大家有什么有趣的想法也欢迎来与我一起讨论~一起来当一个阴间题出题人吧~(不是)

加成券.jpg

分享到: QQ空间 新浪微博 微信 QQ facebook twitter
|推荐阅读
|发表评论
|评论列表
加载更多