exec란?
exec는 현재 프로세스를 새로운 프로세스로 대체하는 함수이다.
주로, 프로세스를 복제해서 새로운 프로세스를 만드는 fork 와 같이 사용된다.
만들어진 프로세스를 자식프로세스, 만든 프로세스를 부모 프로세스라고 하고 자식 프로세스에서 exec
를 해서 새로운 프로그램을 실행한다.
git이 command를 실행하는 방법
git
이 command
를 실행하는 방법을 살펴보자
int start_command(struct child_process *cmd)
{
// 생략
cmd->pid = fork();
// 생략
}
먼저 본인 프로세스를 복제하고, 부모프로세스라면 자식프로세스 PID 값을, 자식 프로세스라면 0을 반환 받는다.
int start_command(struct child_process *cmd)
{
// 생략
cmd->pid = fork();
failed_errno = errno;
if (!cmd->pid) {
// 자식 프로세스 동작
}
// 생략
}
자식 프로세스는 PID값으로 0을 반환받았기 때문에, 조건문안에 있는 동작이 실행되고 부모 프로세스는 자식 PID값을 가지고 있으므로 조건문을 건너뛴다.
int start_command(struct child_process *cmd)
{
// 생략
cmd->pid = fork();
failed_errno = errno;
if (!cmd->pid) {
// 일부 동작 생략
/*
* Attempt to exec using the command and arguments starting at
* argv.argv[1]. argv.argv[0] contains SHELL_PATH which will
* be used in the event exec failed with ENOEXEC at which point
* we will try to interpret the command using 'sh'.
*/
execve(argv.v[1], (char *const *) argv.v + 1,
(char *const *) childenv);
if (errno == ENOEXEC)
execve(argv.v[0], (char *const *) argv.v,
(char *const *) childenv);
}
// 생략
}
자식프로세스는 exec
를 통해 새로운 commnad
프로세스가 되거나, sh
프로세스가 되어 command
프로그램을 실행한다.
이때, sh
프로세스가 되어 다른 프로그램을 실행하는것을 기억해주길 바란다.
위 동작이 수행되는 순간 자식 프로세스는 완전 독립적인 다른 프로세스가 되었으므로 이후에 있는 코드들이 실행되지 않는다.
int start_command(struct child_process *cmd)
{
// 생략
cmd->pid = fork();
failed_errno = errno;
if (!cmd->pid) {
// 자식 프로세스 동작
}
/*
* Wait for child's exec. If the exec succeeds (or if fork()
* failed), EOF is seen immediately by the parent. Otherwise, the
* child process sends a child_err struct.
* Note that use of this infrastructure is completely advisory,
* therefore, we keep error checks minimal.
*/
close(notify_pipe[1]);
if (xread(notify_pipe[0], &cerr, sizeof(cerr)) == sizeof(cerr)) {
/*
* At this point we know that fork() succeeded, but exec()
* failed. Errors have been reported to our stderr.
*/
wait_or_whine(cmd->pid, cmd->args.v[0], 0);
child_err_spew(cmd, &cerr);
failed_errno = errno;
cmd->pid = -1;
}
close(notify_pipe[0]);
// 생략
}
부모 프로세스는 자식 프로세스가 죽기를 기다렸다가 동작을 수행한다.
Node가 exec하는 방법
Node 20 - Chile Processnode
의 child process
는 기본적으로 4개의 함수가 있다
- exec()
- execFile()
- fork()
- spawn()
이 중 가장 기본동작인 exec()부터 알아보자
child_process.exec()
child_process.exec(command[, options][, callback])
동작을 내가 아는 상식으로 예측해보자.
child process라는 이름으로 수행하니까 fork
를 통해 자식 프로세스를 생성하여 exec
를 호출한다고 생각 할 수 있고, 첫번째 인자로 있는 command
라는 프로그램이 자식 프로세스로 생성되겠군,,,
라고 예상하고 예제 코드의 동작을 살펴보면 이상함을 느낄 수 있다.
const { exec } = require('node:child_process');
exec('cat *.js missing_file | wc -l', (error, stdout, stderr) => {
if (error) {
console.error(`exec error: ${error}`);
return;
}
console.log(`stdout: ${stdout}`);
console.error(`stderr: ${stderr}`);
});
바로 |
이다. 파이프는 shell
이 제공하는 기능 중 하나로 단순히 command
라는 프로세스로 대체하지 않는 다는 뜻이다.
공식 문서에 따르면,
Spawns a shell then executes the command within that shell, buffering any generated output. The command string passed to the exec function is processed directly by the shell and special characters (vary based on shell) need to be dealt with accordingly:
이처럼 exec
동작은 shell을 소환해서 command
프로그램을 실행 한다는것을 알 수 있다.
익숙하지 않은가?
git
또한 exec
를 시도할때 실패하면 shell을 통해서 동작을 실행하였다.
추측하자면, 어떤 스크립트는 셔뱅(#!/bin/sh
같은)을 포함하지 않는 경우 처럼 shell을 통해서만 실행 할 수 있는 경우가 잦은 일이지 않을까 생각 들기도 한다.
또한, 다음 함수에서 알아보겠지만 shell이 소환되기 때문에 I/O redirection이나 file globbing이 지원될 수 있다.
이러한 이유에서 node
의 exec
는 shell을 통해서 command
프로그램을 실행하지 않을까 생각해 볼 수 있었다.
공식 문서에서도 node
의 exec
는 우리가 아는 exec
가 아님을 인정하고 있다.
Unlike the exec(3) POSIX system call, child_process.exec() does not replace the existing process and uses a shell to execute the command.
그렇다면, node에서는 우리가 아는 exec
를 활용하지 못하는 것인가? 다음 함수를 보며 알아보자.
child_process.execFile()
함수명을 보고 눈치 챘는가? File이라는 단어까지 붙으며 정말 지정해준 프로그램만을 실행할 것 같은 함수명이다.
child_process.execFile(file[, args][, options][, callback])
The child_process.execFile() function is similar to child_process.exec() except that it does not spawn a shell by default. Rather, the specified executable file is spawned directly as a new process making it slightly more efficient than child_process.exec().
그렇다! 공식 문서 내용만 읽어보아도 우리가 알던 exec
이다. 심지어 shell을 거치지 않기 때문에 더 효율적이라고한다.
하지만, shell이 소환되지 않았기 때문에 I/O redirection과 file globbing이 지원되지 않는다.
예제 코드를 보자.
const { execFile } = require('node:child_process');
const child = execFile('node', ['--version'], (error, stdout, stderr) => {
if (error) {
throw error;
}
console.log(stdout);
});
보다싶이 --version
를 실행 인자로 주며 node
를 실행하고, 예상되는 결과로는 node
의 버전이 잘 출력될 것이다.
앞선, exec
에서처럼 |
파이프는 shell이 제공하는 기능이므로 수행될 수 없을 것 이다.
EXEC Injection (feat. SQL injection)
얼마 전, Git Challenge최종 발표날에 분명 git
명령만 실행해야할 우리의 프로세스에 실행되면 안될 코드가 실행 되었다.
git init
rm -rf /
원래는 enter를 치면 바로 서버에 내용이 전송되어 보통은 개행을 할 수 없는데, 강제 개행을 통해 이러한 입력이 들어왔고, 밑에 있는 절대 실행되면 안될 명령이 실행 되었다.
저 명령은 왜 실행되었을까?
앞서 열심히 써놓은 반성문을 보아서 알겠지만 우리 서버의 코드는 execFile
이 아닌 exec
로 되어 있었다. 따라서, 저 두 명령이 모두 수행되었고 서버는 죽었다...
한마디로 EXEC Injection
공격을 당한것이다.
그러면 이러한 공격은 막을 수 없는것일까?
아니다. 실행될 프로그램파일을 직접 지정해주면, 지정된 프로그램 말고는 실행 될 수 없다. 우리 프로젝트의 서버는 exec
로 도커를 실행시키고, 도커가 컨테이너 내부에 sh
를 통해 프로그램을 실행하고 명령을 전달한다.
이제는, execFile
로 도커를 실행시키자. 이러면 도커만 실행되고 다른 rm
과 같은 프로그램이 실행될 수 없다.
이제는 docker exec sh
가 아닌 docker exec git
을 통해 git을 실행하자. 이러면 컨테이너 내부에서 git을 제외한 동작은 수행할 수 없게 된다.